anvilsign in

collin/anvil

1//! Server-rendered web UI (Maud): repo list, repo overview, tree browsing, and
2//! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
3//! progressive enhancement is a follow-up.
4
5use std::{
6 collections::{
7 BTreeMap,
8 HashMap,
9 },
10 path::PathBuf,
11 sync::{
12 Arc,
13 Mutex,
14 OnceLock,
15 },
16};
17
18use anvil_core::{
19 ApiToken,
20 App,
21 CiRun,
22 Repository,
23 SshKey,
24 User,
25 access,
26 api_tokens,
27 ci,
28 repos,
29 ssh_keys,
30 users,
31};
32use anvil_git::browse::{
33 self,
34 ChangeKind,
35 FileChange,
36};
37use axum::{
38 Form,
39 Router,
40 extract::{
41 Path,
42 Query,
43 State,
44 },
45 http::{
46 StatusCode,
47 header,
48 },
49 response::{
50 IntoResponse,
51 Redirect,
52 Response,
53 },
54 routing::{
55 get,
56 post,
57 },
58};
59use maud::{
60 DOCTYPE,
61 Markup,
62 PreEscaped,
63 html,
64};
65use similar::{
66 ChangeTag,
67 TextDiff,
68};
69use syntect::{
70 easy::HighlightLines,
71 highlighting::{
72 Theme,
73 ThemeSet,
74 },
75 html::{
76 IncludeBackground,
77 styled_line_to_highlighted_html,
78 },
79 parsing::SyntaxSet,
80};
81use time::OffsetDateTime;
82
83use crate::{
84 auth::{
85 CSRF_FIELD,
86 Csrf,
87 CurrentUser,
88 verify_csrf,
89 },
90 todomd,
91};
92
93const STYLE: &str = r#"
94:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
95* { box-sizing:border-box; }
96body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
97a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
98header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
99.container { max-width:980px; margin:0 auto; padding:0 16px; }
100header.top .container { display:flex; align-items:center; gap:12px; }
101.brand { font-weight:700; font-size:16px; color:var(--fg); }
102main { padding:12px 0 24px; }
103h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
104.muted { color:var(--muted); }
105.repo-list { list-style:none; padding:0; margin:0; }
106.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
107.repo-list .name { font-size:16px; font-weight:600; }
108.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
109.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
110.box .row:first-child { border-top:0; }
111.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
112.box .row a.fc-msg { flex:1; margin-left:24px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; color:var(--muted); font-size:13px; }
113.box .row a.fc-msg:hover { color:var(--accent); }
114.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
115.icon { width:1em; height:1em; flex:none; fill:currentColor; color:var(--muted); vertical-align:-0.125em; }
116.icon.dir { color:#54aeff; }
117.file-actions .btn .icon { color:inherit; }
118table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
119table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
120table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
121.cmds { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:12px 14px; margin:8px 0; font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-x:auto; }
122.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
123.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
124.clone-tabs { display:flex; margin-left:auto; }
125.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:0; margin-left:-1px; position:relative; background:var(--bg); color:var(--muted); cursor:pointer; }
126.clone-tab:first-child { border-radius:2em 0 0 2em; margin-left:0; }
127.clone-tab:last-child { border-radius:0 2em 2em 0; }
128.clone-tab:first-child:last-child { border-radius:2em; }
129.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); z-index:1; }
130.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
131.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
132.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
133.copy-btn:hover { color:var(--fg); }
134.copied-msg { display:none; color:#1a7f37; font-size:12px; }
135.clone.copied .copied-msg { display:inline; }
136.clone.copied .copy-btn { color:#1a7f37; }
137.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
138.pill { display:inline-block; background:var(--code-bg); border:1px solid var(--border); border-radius:2em; padding:1px 8px; font-size:12px; color:var(--muted); }
139.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
140.view-toggle { margin:8px 0; }
141a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
142.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
143.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
144.md-body pre { background:var(--code-bg); border-radius:6px; padding:12px 14px; overflow-x:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
145.md-body code { background:var(--code-bg); border-radius:4px; padding:1px 4px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.9em; }
146.md-body pre code { background:none; padding:0; font-size:inherit; }
147.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
148.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
149.md-body img { max-width:100%; }
150.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
151.linkbtn:hover { text-decoration:underline; }
152.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; }
153.btn:hover { text-decoration:none; opacity:.92; }
154/* Repo header: title (+ visibility badge) on the left, quick-nav on the right;
155 wraps cleanly to its own line on narrow viewports instead of floating. */
156.repo-head { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:6px 16px; margin:24px 0 4px; }
157.repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; }
158.repo-title h1 { margin:0; }
159.repo-title .pill { font-size:11px; text-transform:uppercase; letter-spacing:.04em; align-self:center; }
160.repo-nav { font-size:13px; display:flex; align-items:baseline; gap:8px; color:var(--muted); }
161.repo-nav a { color:var(--muted); }
162.repo-nav a:hover { color:var(--accent); text-decoration:none; }
163.repo-nav .sep { color:var(--border); }
164.repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; }
165.repo-meta b { font-weight:600; color:var(--fg); }
166.pill-group { display:inline-flex; }
167.pill-group > .pill { border-radius:0; margin-left:-1px; position:relative; }
168.pill-group > .pill:first-child { border-radius:2em 0 0 2em; margin-left:0; }
169.pill-group > .pill:last-child { border-radius:0 2em 2em 0; }
170form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
171form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
172form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
173form.stack select { padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
174form.stack textarea.editor { max-width:none; font:13px/1.5 ui-monospace,monospace; tab-size:4; resize:vertical; }
175p.file-actions { margin:10px 0; display:flex; gap:6px; align-items:center; }
176.file-actions .btn { padding:3px 11px; font-size:12px; font-weight:500; border-radius:6px; display:inline-flex; align-items:center; gap:5px; }
177table.usage { border-collapse:collapse; width:100%; max-width:680px; margin-top:12px; }
178table.usage th, table.usage td { padding:6px 10px; border-bottom:1px solid var(--border); text-align:left; }
179table.usage .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
180table.usage tfoot td { font-weight:600; border-top:2px solid var(--border); border-bottom:none; }
181.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
182.issue-dot.open { background:#1a7f37; }
183.issue-dot.closed { background:#8250df; }
184.st.issue-open { background:#dafbe1; color:#1a7f37; }
185.st.issue-closed { background:#fbefff; color:#8250df; }
186.issue-post { margin:12px 0; }
187.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
188.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
189.readme { margin-top:16px; }
190.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
191/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
192 nested frames. */
193.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
194.kanban .col { flex:1 1 0; min-width:240px; }
195.kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); }
196.kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
197.kanban .card { position:relative; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
198.kanban .card-del { position:absolute; top:3px; right:4px; margin:0; }
199.kanban .card-del-btn { border:0; background:none; color:var(--muted); cursor:pointer; font-size:16px; line-height:1; padding:1px 5px; border-radius:4px; opacity:0; transition:opacity .1s,background .1s; }
200.kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; }
201.kanban .card-del-btn:hover { color:#cf222e; background:var(--code-bg); }
202.kanban .card .title { padding-right:14px; }
203.kanban .card:has(.card-grip) { padding-left:28px; }
204.kanban .card-grip { position:absolute; left:2px; top:5px; color:var(--muted); cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; line-height:0; padding:4px 5px; border-radius:4px; }
205/* The touch must land on the grip (touch-action:none), not the icon inside it,
206 or the browser claims the gesture for scrolling and never drags. */
207.kanban .card-grip svg { pointer-events:none; }
208.kanban .card-grip:hover { color:var(--fg); background:var(--code-bg); }
209.kanban .card.dragging { opacity:.4; pointer-events:none; }
210.kanban .card.dragging .card-grip { pointer-events:auto; cursor:grabbing; }
211.kanban .card .title p { margin:0; font-weight:500; }
212.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
213.kanban .card details { margin-top:7px; }
214.kanban .card summary { cursor:pointer; font-size:11px; font-weight:500; letter-spacing:.03em; text-transform:uppercase; color:var(--muted); list-style:none; display:inline-flex; align-items:center; gap:5px; user-select:none; }
215.kanban .card summary:hover { color:var(--accent); }
216.kanban .card summary::-webkit-details-marker { display:none; }
217.kanban .card summary::before { content:"\25B8"; font-size:9px; transition:transform .15s ease; }
218.kanban .card details[open] summary { margin-bottom:5px; }
219.kanban .card details[open] summary::before { transform:rotate(90deg); }
220.kanban .card .card-details { font-size:13px; color:var(--fg); line-height:1.5; }
221.kanban .card .card-details p { margin:0 0 6px; }
222.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
223.kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
224.kanban .card .card-details > :last-child { margin-bottom:0; }
225.kanban .card .title img { max-width:100%; height:auto; border-radius:4px; }
226.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
227.todo-notes { margin:8px 2px; }
228.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
229.latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
230.latest-commit + .box { border-radius:0 0 6px 6px; }
231.commit-list { list-style:none; padding:0; margin:0; }
232.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
233.commit-list li:first-child { border-top:0; }
234.sha { font:12px ui-monospace,monospace; color:var(--muted); }
235.file-diff { margin:16px 0; }
236.file-diff summary.head { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 12px; font:12px ui-monospace,monospace; cursor:pointer; display:flex; align-items:center; gap:8px; list-style:none; }
237.file-diff summary.head::-webkit-details-marker { display:none; }
238.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
239.file-diff[open] summary.head::before { content:"\25BE"; }
240.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
241.file-diff .stat { margin-left:auto; white-space:nowrap; }
242.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
243table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
244table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
245table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
246table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
247table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
248.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
249.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
250.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
251.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
252.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
253.log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; }
254footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
255details.nav-menu { position:relative; }
256details.nav-menu > summary { list-style:none; cursor:pointer; color:var(--accent); font-size:14px; }
257details.nav-menu > summary::-webkit-details-marker { display:none; }
258details.nav-menu > summary::after { content:""; display:inline-block; width:0; height:0; margin-left:6px; vertical-align:middle; border:4px solid transparent; border-top:5px solid var(--muted); border-bottom:0; transition:transform .15s ease; }
259details.nav-menu > summary:hover::after { border-top-color:var(--accent); }
260details.nav-menu[open] > summary::after { transform:rotate(180deg); }
261.nav-dropdown { position:absolute; right:0; top:calc(100% + 6px); background:var(--bg); border:1px solid var(--border); border-radius:6px; min-width:130px; box-shadow:0 4px 14px rgba(0,0,0,.1); z-index:200; padding:4px 0; }
262.nav-dropdown a, .nav-dropdown button { display:block; width:100%; padding:6px 14px; font-size:13px; color:var(--fg); text-align:left; background:none; border:0; cursor:pointer; font:inherit; text-decoration:none; }
263.nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); }
264.nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; }
265.nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
266.nav-dropdown a.current { font-weight:600; }
267details.rev-menu { display:inline-block; }
268details.rev-menu > summary .pill { cursor:pointer; }
269@media (max-width:720px) {
270 .kanban { flex-direction:column; gap:14px; overflow-x:visible; }
271 .kanban .col { min-width:0; width:100%; }
272}
273"#;
274
275/// Icon set as an SVG sprite (a hidden `<svg>` of `<symbol id="i-…">`s),
276/// authored in `assets/icons.svg` and embedded at compile time. The layout
277/// emits it once per page; [`icon`] references a symbol via `<use>`, so the
278/// path data is never duplicated in the rendered HTML.
279const ICON_SPRITE: &str = include_str!("../assets/icons.svg");
280
281/// The icons defined in the sprite. Each maps to a `<symbol id="i-…">` in
282/// `assets/icons.svg` — keep the two in sync.
283#[derive(Clone, Copy)]
284pub(crate) enum Icon {
285 Clipboard,
286 Pencil,
287 Plus,
288 Folder,
289 File,
290 Grip,
291}
292
293impl Icon {
294 /// The sprite symbol id (`<symbol id="…">`).
295 fn id(self) -> &'static str {
296 match self {
297 Icon::Clipboard => "i-clipboard",
298 Icon::Pencil => "i-pencil",
299 Icon::Plus => "i-plus",
300 Icon::Folder => "i-folder",
301 Icon::File => "i-file",
302 Icon::Grip => "i-grip",
303 }
304 }
305}
306
307/// Reference a sprite symbol as an inline `<svg>`, sized/colored by the `.icon`
308/// CSS (1em, `currentColor`).
309pub(crate) fn icon(i: Icon) -> Markup {
310 icon_with(i, "icon")
311}
312
313/// Like [`icon`] but with custom classes (e.g. `"icon dir"` to tint a folder).
314fn icon_with(i: Icon, class: &str) -> Markup {
315 PreEscaped(format!(
316 r##"<svg class="{class}" aria-hidden="true"><use href="#{}"></use></svg>"##,
317 i.id()
318 ))
319}
320
321/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
322/// Registered once on `document`, so it survives htmx body swaps.
323const CLONE_JS: &str = r#"
324(function(){
325 function copyText(t){
326 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
327 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
328 document.body.appendChild(ta); ta.focus(); ta.select();
329 try{document.execCommand('copy')}catch(e){}
330 document.body.removeChild(ta); return Promise.resolve();
331 }
332 document.addEventListener('click', function(e){
333 var nm=e.target.closest('details.nav-menu');
334 document.querySelectorAll('details.nav-menu').forEach(function(d){ if(d!==nm) d.removeAttribute('open'); });
335 var tab=e.target.closest('.clone-tab');
336 if(tab){
337 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
338 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
339 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
340 return;
341 }
342 var copy=e.target.closest('.copy-btn');
343 if(copy){
344 var box=copy.closest('.clone');
345 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
346 box.classList.add('copied');
347 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
348 });
349 }
350 });
351})();
352"#;
353
354/// Mount the web UI routes.
355pub fn routes(router: Router<App>) -> Router<App> {
356 router
357 .route("/", get(home))
358 .route("/-/settings", get(account_settings))
359 .route("/-/settings/keys", post(add_ssh_key))
360 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
361 .route("/-/settings/tokens", post(create_token))
362 .route("/-/settings/tokens/{id}/delete", post(revoke_token))
363 .route("/-/new", get(new_repo_form).post(new_repo_submit))
364 .route("/{username}", get(user_profile))
365 .route(
366 "/{owner}/{repo}/settings",
367 get(repo_settings).post(repo_settings_submit),
368 )
369 .route("/{owner}/{repo}", get(repo_index))
370 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
371 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
372 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
373 .route(
374 "/{owner}/{repo}/edit/{rev}/{*path}",
375 get(edit_form).post(edit_submit),
376 )
377 .route(
378 "/{owner}/{repo}/add-task/{rev}/{*path}",
379 get(add_task_form).post(add_task_submit),
380 )
381 .route(
382 "/{owner}/{repo}/delete-task/{rev}/{*path}",
383 post(delete_task),
384 )
385 .route("/{owner}/{repo}/move-task/{rev}/{*path}", post(move_task))
386 .route("/{owner}/{repo}/commits/{rev}", get(commits))
387 .route("/{owner}/{repo}/commit/{id}", get(commit))
388 .route("/{owner}/{repo}/ci", get(ci_runs))
389 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
390 .route("/-/static/htmx.min.js", get(htmx_js))
391}
392
393/// Serve the vendored htmx script (embedded in the binary).
394async fn htmx_js() -> Response {
395 (
396 [(
397 header::CONTENT_TYPE,
398 "application/javascript; charset=utf-8",
399 )],
400 include_str!("../assets/htmx.min.js"),
401 )
402 .into_response()
403}
404
405pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
406 // Attach the session's CSRF token to every htmx request as a header, so any
407 // JS-driven action carries it without a hidden field. Omitted (no attribute)
408 // when unauthenticated. The token is hex, so it needs no JSON escaping.
409 let csrf = crate::auth::current_csrf();
410 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
411 html! {
412 (DOCTYPE)
413 html lang="en" {
414 head {
415 meta charset="utf-8";
416 meta name="viewport" content="width=device-width, initial-scale=1";
417 title { (title) " · anvil" }
418 style { (PreEscaped(STYLE)) }
419 }
420 body hx-boost="true" hx-headers=[hx_headers] {
421 (PreEscaped(ICON_SPRITE))
422 header.top { div.container {
423 a.brand href="/" { "anvil" }
424 span style="margin-left:auto" {
425 @match user {
426 Some(u) => {
427 details.nav-menu {
428 summary { (u.username) }
429 div.nav-dropdown {
430 a href="/-/settings" { "Settings" }
431 @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } }
432 form method="post" action="/-/logout" {
433 button type="submit" { "Sign out" }
434 }
435 }
436 }
437 }
438 None => { a href="/-/login" { "sign in" } }
439 }
440 }
441 } }
442 main { div.container { (body) } }
443 footer { div.container { "anvil — a git forge" } }
444 script src="/-/static/htmx.min.js" {}
445 script { (PreEscaped(CLONE_JS)) }
446 }
447 }
448 }
449}
450
451/// Hidden CSRF token field for embedding inside a mutating `<form>`.
452pub(crate) fn csrf_input(token: &str) -> Markup {
453 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
454}
455
456pub(crate) fn not_found(message: &str) -> Response {
457 (
458 StatusCode::NOT_FOUND,
459 layout(
460 "Not found",
461 None,
462 html! { h1 { "Not found" } p.muted { (message) } },
463 ),
464 )
465 .into_response()
466}
467
468pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
469 tracing::error!("ui error: {err}");
470 (
471 StatusCode::INTERNAL_SERVER_ERROR,
472 layout("Error", None, html! { h1 { "Something went wrong" } }),
473 )
474 .into_response()
475}
476
477/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
478/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
479pub(crate) async fn resolve_repo(
480 app: &App,
481 viewer: Option<&User>,
482 owner: &str,
483 name: &str,
484) -> Result<(PathBuf, Repository), Response> {
485 let owner_user = users::find_by_username(&app.db, owner)
486 .await
487 .map_err(server_error)?
488 .ok_or_else(|| not_found("no such user"))?;
489 let repo = repos::find(&app.db, owner_user.id, name)
490 .await
491 .map_err(server_error)?
492 .ok_or_else(|| not_found("no such repository"))?;
493 if !access::can_read(&repo, viewer) {
494 return Err(not_found("no such repository"));
495 }
496 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
497 if !path.exists() {
498 return Err(not_found("repository not found on disk"));
499 }
500 Ok((path, repo))
501}
502
503/// `GET /` — list repositories visible to the current user.
504async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
505 let all = repos::list_all_with_owner(&app.db)
506 .await
507 .map_err(server_error)?;
508 let repos: Vec<_> = all
509 .into_iter()
510 .filter(|r| {
511 !r.is_private
512 || user
513 .as_ref()
514 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
515 })
516 .collect();
517 Ok(layout(
518 "Repositories",
519 user.as_ref(),
520 html! {
521 div style="display:flex;align-items:center" {
522 h1 style="margin-right:auto" { "Repositories" }
523 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
524 }
525 @if repos.is_empty() {
526 p.muted {
527 "No repositories yet. "
528 @if user.is_some() { a href="/-/new" { "Create one" } "." }
529 @else { "Sign in to create one." }
530 }
531 } @else {
532 ul.repo-list {
533 @for r in &repos {
534 li {
535 div.name {
536 a href=(format!("/{}", r.owner)) { (r.owner) }
537 "/"
538 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
539 @if r.is_private { " " span.pill { "private" } }
540 @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
541 }
542 @if !r.description.is_empty() { div.muted { (r.description) } }
543 }
544 }
545 }
546 }
547 },
548 ))
549}
550
551/// `GET /{username}` — a user's profile: their repositories (public to all;
552/// private only to themselves or an admin).
553async fn user_profile(
554 State(app): State<App>,
555 CurrentUser(viewer): CurrentUser,
556 Path(username): Path<String>,
557) -> Result<Markup, Response> {
558 let owner = users::find_by_username(&app.db, &username)
559 .await
560 .map_err(server_error)?
561 .ok_or_else(|| not_found("no such user"))?;
562 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
563 .await
564 .map_err(server_error)?
565 .into_iter()
566 .filter(|r| access::can_read(r, viewer.as_ref()))
567 .collect();
568 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
569
570 Ok(layout(
571 &owner.username,
572 viewer.as_ref(),
573 html! {
574 div style="display:flex;align-items:center" {
575 h1 style="margin-right:auto" { (owner.username) }
576 @if is_self { a.btn href="/-/new" { "New repository" } }
577 }
578 h2 { "Repositories" }
579 @if visible.is_empty() {
580 p.muted { "No repositories." }
581 } @else {
582 ul.repo-list {
583 @for r in &visible {
584 li {
585 div.name {
586 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
587 @if r.is_private { " " span.pill { "private" } }
588 @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
589 }
590 @if !r.description.is_empty() { div.muted { (r.description) } }
591 }
592 }
593 }
594 }
595 },
596 ))
597}
598
599#[derive(serde::Deserialize)]
600struct AddKeyForm {
601 #[serde(default)]
602 title: String,
603 key: String,
604 #[serde(default)]
605 csrf: String,
606}
607
608/// `GET /settings` — account settings: profile + SSH keys.
609async fn account_settings(
610 State(app): State<App>,
611 CurrentUser(user): CurrentUser,
612 csrf: Csrf,
613) -> Response {
614 let Some(user) = user else {
615 return Redirect::to("/-/login").into_response();
616 };
617 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
618 Ok(keys) => keys,
619 Err(e) => return server_error(e),
620 };
621 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
622 account_page(&user, &keys, &tokens, None, None, &csrf.0).into_response()
623}
624
625/// `POST /settings/keys` — register an SSH public key for the current user.
626async fn add_ssh_key(
627 State(app): State<App>,
628 CurrentUser(user): CurrentUser,
629 csrf: Csrf,
630 Form(form): Form<AddKeyForm>,
631) -> Response {
632 let Some(user) = user else {
633 return Redirect::to("/-/login").into_response();
634 };
635 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
636 return resp;
637 }
638 let result = match ssh_keys::parse_public_key(&form.key) {
639 Ok((fingerprint, content)) => {
640 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
641 .await
642 .map(|_| ())
643 }
644 Err(e) => Err(e),
645 };
646 match result {
647 Ok(()) => Redirect::to("/-/settings").into_response(),
648 Err(e) => {
649 let keys = ssh_keys::list_by_user(&app.db, user.id)
650 .await
651 .unwrap_or_default();
652 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
653 (
654 StatusCode::BAD_REQUEST,
655 account_page(&user, &keys, &tokens, None, Some(&e.to_string()), &csrf.0),
656 )
657 .into_response()
658 }
659 }
660}
661
662#[derive(serde::Deserialize)]
663struct CreateTokenForm {
664 #[serde(default)]
665 name: String,
666 #[serde(default)]
667 csrf: String,
668}
669
670/// `POST /settings/tokens` — mint a read-only PAT for the current user and show
671/// the plaintext once (it's only stored hashed, so it can't be shown again).
672async fn create_token(
673 State(app): State<App>,
674 CurrentUser(user): CurrentUser,
675 csrf: Csrf,
676 Form(form): Form<CreateTokenForm>,
677) -> Response {
678 let Some(user) = user else {
679 return Redirect::to("/-/login").into_response();
680 };
681 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
682 return resp;
683 }
684 let name = match form.name.trim() {
685 "" => "api",
686 n => n,
687 };
688 let plaintext = match api_tokens::create(&app.db, user.id, name, api_tokens::READ).await {
689 Ok((_, plaintext)) => plaintext,
690 Err(e) => return server_error(e),
691 };
692 let keys = ssh_keys::list_by_user(&app.db, user.id)
693 .await
694 .unwrap_or_default();
695 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
696 account_page(&user, &keys, &tokens, Some(&plaintext), None, &csrf.0).into_response()
697}
698
699/// `POST /settings/tokens/{id}/delete` — revoke one of the current user's
700/// tokens (ownership enforced: a user can only revoke their own).
701async fn revoke_token(
702 State(app): State<App>,
703 CurrentUser(user): CurrentUser,
704 csrf: Csrf,
705 Path(id): Path<i64>,
706 Form(form): Form<crate::auth::CsrfForm>,
707) -> Response {
708 let Some(user) = user else {
709 return Redirect::to("/-/login").into_response();
710 };
711 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
712 return resp;
713 }
714 let owned = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
715 if owned.iter().any(|t| t.id == id)
716 && let Err(e) = api_tokens::revoke(&app.db, id).await
717 {
718 return server_error(e);
719 }
720 Redirect::to("/-/settings").into_response()
721}
722
723/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
724async fn delete_ssh_key(
725 State(app): State<App>,
726 CurrentUser(user): CurrentUser,
727 csrf: Csrf,
728 Path(id): Path<i64>,
729 Form(form): Form<crate::auth::CsrfForm>,
730) -> Response {
731 let Some(user) = user else {
732 return Redirect::to("/-/login").into_response();
733 };
734 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
735 return resp;
736 }
737 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
738 return server_error(e);
739 }
740 Redirect::to("/-/settings").into_response()
741}
742
743#[allow(clippy::too_many_arguments)]
744fn account_page(
745 user: &User,
746 keys: &[SshKey],
747 tokens: &[ApiToken],
748 new_token: Option<&str>,
749 error: Option<&str>,
750 csrf: &str,
751) -> Markup {
752 layout(
753 "Account settings",
754 Some(user),
755 html! {
756 h1 { "Account settings" }
757 p.muted {
758 "Signed in as " strong { (user.username) }
759 @if !user.email.is_empty() { " · " (user.email) }
760 }
761
762 h2 { "SSH keys" }
763 p.muted { "Add a public key to clone and push over SSH." }
764 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
765 @if keys.is_empty() {
766 p.muted { "No SSH keys yet." }
767 } @else {
768 div.box {
769 @for k in keys {
770 div.row {
771 div {
772 @if !k.title.is_empty() { strong { (k.title) } " " }
773 span.sha { (k.fingerprint) }
774 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
775 }
776 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
777 (csrf_input(csrf))
778 button.linkbtn type="submit" { "delete" }
779 }
780 }
781 }
782 }
783 }
784
785 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
786 (csrf_input(csrf))
787 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
788 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
789 p { button.btn type="submit" { "Add SSH key" } }
790 }
791
792 h2 style="margin-top:28px" { "Personal access tokens" }
793 p.muted { "Read-only API tokens for tooling (e.g. fetching attachments over HTTP). The secret is shown once, at creation." }
794 @if let Some(token) = new_token {
795 div.box style="border-color:var(--accent)" {
796 p style="margin-top:0" { strong { "New token — copy it now; it won't be shown again." } }
797 pre.cmds { (token) }
798 }
799 }
800 @if tokens.is_empty() {
801 p.muted { "No tokens yet." }
802 } @else {
803 div.box {
804 @for t in tokens {
805 div.row {
806 div {
807 strong { (t.name) } " " span.pill { (t.scopes) }
808 div.muted style="font-size:12px" { "added " (fmt_time(t.created_at)) }
809 }
810 form method="post" action=(format!("/-/settings/tokens/{}/delete", t.id)) {
811 (csrf_input(csrf))
812 button.linkbtn type="submit" { "revoke" }
813 }
814 }
815 }
816 }
817 }
818 form.stack method="post" action="/-/settings/tokens" style="margin-top:16px" {
819 (csrf_input(csrf))
820 p { label { "Name" br; input type="text" name="name" placeholder="claude"; } }
821 p { button.btn type="submit" { "Create token" } }
822 }
823 },
824 )
825}
826
827pub(crate) fn forbidden() -> Response {
828 (
829 StatusCode::FORBIDDEN,
830 layout(
831 "Forbidden",
832 None,
833 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
834 ),
835 )
836 .into_response()
837}
838
839#[derive(serde::Deserialize)]
840struct NewRepoForm {
841 name: String,
842 #[serde(default)]
843 description: String,
844 private: Option<String>,
845 #[serde(default)]
846 csrf: String,
847}
848
849#[derive(serde::Deserialize)]
850struct SettingsForm {
851 #[serde(default)]
852 description: String,
853 private: Option<String>,
854 #[serde(default)]
855 mirror_url: String,
856 #[serde(default)]
857 csrf: String,
858}
859
860/// `GET /new` — new-repository form (requires login).
861async fn new_repo_form(
862 State(app): State<App>,
863 CurrentUser(user): CurrentUser,
864 csrf: Csrf,
865) -> Response {
866 let Some(user) = user else {
867 return Redirect::to("/-/login").into_response();
868 };
869 let remote = push_remote_url(&app, &user.username, "");
870 new_repo_page(&user, None, "", "", false, &remote, &csrf.0).into_response()
871}
872
873/// The remote URL to suggest for push-to-create: SSH when enabled (pushes
874/// without a credential prompt), otherwise HTTP. `name` may be empty, in which
875/// case a `<name>` placeholder is used.
876fn push_remote_url(app: &App, owner: &str, name: &str) -> String {
877 let name = if name.is_empty() { "<name>" } else { name };
878 if app.config.ssh.enabled {
879 app.config.ssh_clone_url(owner, name)
880 } else {
881 app.config.http_clone_url(owner, name)
882 }
883}
884
885/// `POST /new` — create a repository owned by the current user.
886async fn new_repo_submit(
887 State(app): State<App>,
888 CurrentUser(user): CurrentUser,
889 csrf: Csrf,
890 Form(form): Form<NewRepoForm>,
891) -> Response {
892 let Some(user) = user else {
893 return Redirect::to("/-/login").into_response();
894 };
895 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
896 return resp;
897 }
898 let private = form.private.is_some();
899 match repos::create(
900 &app.db,
901 &app.config.repositories_dir(),
902 &user,
903 &form.name,
904 &form.description,
905 private,
906 )
907 .await
908 {
909 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
910 Err(e) => {
911 let remote = push_remote_url(&app, &user.username, &form.name);
912 (
913 StatusCode::BAD_REQUEST,
914 new_repo_page(
915 &user,
916 Some(&e.to_string()),
917 &form.name,
918 &form.description,
919 private,
920 &remote,
921 &csrf.0,
922 ),
923 )
924 .into_response()
925 }
926 }
927}
928
929fn new_repo_page(
930 user: &User,
931 error: Option<&str>,
932 name: &str,
933 description: &str,
934 private: bool,
935 remote: &str,
936 csrf: &str,
937) -> Markup {
938 layout(
939 "New repository",
940 Some(user),
941 html! {
942 h1 { "New repository" }
943 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
944 form.stack method="post" action="/-/new" {
945 (csrf_input(csrf))
946 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
947 p { label { "Description" br; input type="text" name="description" value=(description); } }
948 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
949 p { button.btn type="submit" { "Create repository" } }
950 }
951 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
952
953 h2 { "…or push an existing repository" }
954 p.muted { "Pushing to a name that doesn't exist yet creates the repository (private). No need for the form above." }
955 pre.cmds { (format!("git remote add origin {remote}\ngit push -u origin main")) }
956 },
957 )
958}
959
960/// Load a repo for an owner-only settings action, enforcing write access.
961async fn resolve_for_settings(
962 app: &App,
963 viewer: Option<&User>,
964 owner: &str,
965 name: &str,
966) -> Result<Repository, Response> {
967 let owner_user = users::find_by_username(&app.db, owner)
968 .await
969 .map_err(server_error)?
970 .ok_or_else(|| not_found("no such repository"))?;
971 let repo = repos::find(&app.db, owner_user.id, name)
972 .await
973 .map_err(server_error)?
974 .ok_or_else(|| not_found("no such repository"))?;
975 if !access::can_read(&repo, viewer) {
976 return Err(not_found("no such repository"));
977 }
978 if !access::can_write(&repo, viewer) {
979 return Err(forbidden());
980 }
981 Ok(repo)
982}
983
984/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
985async fn repo_settings(
986 State(app): State<App>,
987 CurrentUser(user): CurrentUser,
988 csrf: Csrf,
989 Path((owner, repo)): Path<(String, String)>,
990) -> Response {
991 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
992 Ok(m) => m,
993 Err(resp) => return resp,
994 };
995 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
996}
997
998/// `POST /{owner}/{repo}/settings` — update description / visibility.
999async fn repo_settings_submit(
1000 State(app): State<App>,
1001 CurrentUser(user): CurrentUser,
1002 csrf: Csrf,
1003 Path((owner, repo)): Path<(String, String)>,
1004 Form(form): Form<SettingsForm>,
1005) -> Response {
1006 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
1007 Ok(m) => m,
1008 Err(resp) => return resp,
1009 };
1010 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1011 return resp;
1012 }
1013 if let Err(e) = repos::update_settings(
1014 &app.db,
1015 meta.id,
1016 &form.description,
1017 form.private.is_some(),
1018 &form.mirror_url,
1019 )
1020 .await
1021 {
1022 return server_error(e);
1023 }
1024 Redirect::to(&format!("/{owner}/{repo}")).into_response()
1025}
1026
1027fn settings_page(
1028 user: Option<&User>,
1029 owner: &str,
1030 repo: &str,
1031 meta: &Repository,
1032 error: Option<&str>,
1033 csrf: &str,
1034) -> Markup {
1035 layout(
1036 &format!("{owner}/{repo}: settings"),
1037 user,
1038 html! {
1039 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
1040 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1041 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
1042 (csrf_input(csrf))
1043 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
1044 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
1045 p {
1046 label {
1047 "Mirror push URL" br;
1048 input type="text" name="mirror_url" value=(meta.mirror_url)
1049 placeholder="https://x-access-token:<token>@github.com/you/repo.git";
1050 }
1051 br;
1052 span.muted style="font-size:12px" {
1053 "After every push here, all refs are mirrored to this remote ("
1054 code { "git push --mirror" }
1055 "). Stored as-is — use a scoped token. Empty disables it."
1056 }
1057 }
1058 p { button.btn type="submit" { "Save changes" } }
1059 }
1060 },
1061 )
1062}
1063
1064fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
1065 let http = app.config.http_clone_url(owner, name);
1066 let ssh = app
1067 .config
1068 .ssh
1069 .enabled
1070 .then(|| app.config.ssh_clone_url(owner, name));
1071 // SSH first and preselected when available — it's the protocol that can
1072 // push without a credential prompt.
1073 let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
1074 html! {
1075 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
1076 div.clone-head {
1077 span.muted { "Clone" }
1078 div.clone-tabs {
1079 @if ssh.is_some() {
1080 button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
1081 button.clone-tab type="button" data-proto="http" { "HTTP" }
1082 } @else {
1083 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
1084 }
1085 }
1086 }
1087 div.clone-cmd {
1088 code { (default_cmd) }
1089 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
1090 (icon(Icon::Clipboard))
1091 }
1092 span.copied-msg { "Copied!" }
1093 }
1094 }
1095 }
1096}
1097
1098/// `GET /{owner}/{repo}` — repository overview with the root tree.
1099async fn repo_index(
1100 State(app): State<App>,
1101 CurrentUser(user): CurrentUser,
1102 Path((owner, repo)): Path<(String, String)>,
1103) -> Result<Markup, Response> {
1104 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1105 let overview = browse::overview(&path).map_err(server_error)?;
1106
1107 let can_write = access::can_write(&meta, user.as_ref());
1108 let header = html! {
1109 div.repo-head {
1110 span.repo-title {
1111 h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) }
1112 @if meta.is_private { span.pill { "private" } }
1113 }
1114 nav.repo-nav {
1115 a href=(format!("/{owner}/{repo}/blob/{}/TODO.md", enc_ref(overview.default_branch.as_deref().unwrap_or("main")))) { "Todo" }
1116 span.sep { "·" }
1117 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1118 span.sep { "·" }
1119 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
1120 @if can_write {
1121 span.sep { "·" }
1122 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
1123 }
1124 }
1125 }
1126 @if !meta.description.is_empty() { p.muted { (meta.description) } }
1127 p.repo-meta {
1128 span { b { (overview.branches.len()) } " " (plural(overview.branches.len(), "branch", "branches")) }
1129 span { b { (overview.tags.len()) } " " (plural(overview.tags.len(), "tag", "tags")) }
1130 }
1131 (clone_box(&app, &owner, &repo))
1132 };
1133
1134 if overview.is_empty {
1135 return Ok(layout(
1136 &format!("{owner}/{repo}"),
1137 user.as_ref(),
1138 html! {
1139 (header)
1140 p.muted { "This repository is empty. Push to it to get started." }
1141 },
1142 ));
1143 }
1144
1145 let rev = overview
1146 .default_branch
1147 .clone()
1148 .unwrap_or_else(|| "HEAD".to_string());
1149 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
1150 let latest = browse::commit_log(&path, &rev, 1)
1151 .map_err(server_error)?
1152 .into_iter()
1153 .next();
1154 // Best-effort: a failed walk only costs the per-entry annotations.
1155 let entry_commits =
1156 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
1157
1158 // A root README renders below the tree, GitHub-style. Best-effort: a
1159 // missing or unreadable file just omits the section.
1160 let readme = entries
1161 .iter()
1162 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
1163 .and_then(|e| {
1164 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1165 Some((
1166 render_markdown(&String::from_utf8_lossy(&bytes)),
1167 e.name.clone(),
1168 ))
1169 });
1170
1171 // A root TODO.md with tasks renders as a kanban board below the README.
1172 let todo_board = entries
1173 .iter()
1174 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
1175 .and_then(|e| {
1176 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1177 let board = todomd::render_board(&String::from_utf8_lossy(&bytes), None)?;
1178 Some((board, e.name.clone()))
1179 });
1180
1181 Ok(layout(
1182 &format!("{owner}/{repo}"),
1183 user.as_ref(),
1184 html! {
1185 (header)
1186 p {
1187 (rev_switcher(&owner, &repo, &rev, &overview))
1188 " · "
1189 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
1190 }
1191 @if let Some(c) = &latest {
1192 div.latest-commit {
1193 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1194 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
1195 span.muted style="margin-left:auto" {
1196 (c.author) " · "
1197 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1198 }
1199 }
1200 }
1201 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
1202 @if let Some(lang_bar) = render_languages_bar(&meta.languages_json) {
1203 div.box {
1204 div.readme-head { "Languages" }
1205 div style="padding:8px 16px;" { (lang_bar) }
1206 }
1207 }
1208 @if let Some((rendered, name)) = &readme {
1209 div.box.readme {
1210 div.readme-head {
1211 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1212 }
1213 div.md-body { (rendered) }
1214 }
1215 }
1216 @if let Some((board, name)) = &todo_board {
1217 p.todo-board-head {
1218 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1219 }
1220 (board)
1221 }
1222 },
1223 ))
1224}
1225
1226async fn tree_root(
1227 State(app): State<App>,
1228 user: CurrentUser,
1229 Path((owner, repo, rev)): Path<(String, String, String)>,
1230) -> Result<Markup, Response> {
1231 render_tree(&app, user, &owner, &repo, &rev, "").await
1232}
1233
1234async fn tree_path(
1235 State(app): State<App>,
1236 user: CurrentUser,
1237 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1238) -> Result<Markup, Response> {
1239 render_tree(&app, user, &owner, &repo, &rev, &path).await
1240}
1241
1242async fn render_tree(
1243 app: &App,
1244 CurrentUser(user): CurrentUser,
1245 owner: &str,
1246 repo: &str,
1247 rev: &str,
1248 path: &str,
1249) -> Result<Markup, Response> {
1250 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
1251 let overview = browse::overview(&repo_path).map_err(server_error)?;
1252 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
1253 // Best-effort: a failed walk only costs the per-entry annotations.
1254 let entry_commits =
1255 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
1256 Ok(layout(
1257 &format!("{owner}/{repo}: {path}"),
1258 user.as_ref(),
1259 html! {
1260 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1261 p { (rev_switcher(owner, repo, rev, &overview)) }
1262 (breadcrumbs(owner, repo, rev, path, false))
1263 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
1264 },
1265 ))
1266}
1267
1268/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
1269/// by default; `?plain=1` shows the raw source (toggle links on the page).
1270async fn blob(
1271 State(app): State<App>,
1272 CurrentUser(user): CurrentUser,
1273 csrf: Csrf,
1274 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1275 Query(query): Query<HashMap<String, String>>,
1276) -> Result<Markup, Response> {
1277 let (repo_path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1278 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
1279 .map_err(server_error)?
1280 .ok_or_else(|| not_found("file not found"))?;
1281
1282 // Editing writes a commit onto a branch, so it's offered only to writers
1283 // viewing a text file at a branch tip (not a tag or detached commit). The
1284 // resolved tip is the compare-and-swap guard for board delete actions.
1285 let edit_tip = (!is_binary(&bytes) && access::can_write(&meta, user.as_ref()))
1286 .then(|| browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).ok())
1287 .flatten();
1288 let can_edit = edit_tip.is_some();
1289
1290 let markdown = is_markdown(&path) && !is_binary(&bytes);
1291 // Custom renderers for well-known filenames (the plugin point — add new
1292 // filename → renderer pairs here). TODO.md defaults to a kanban board.
1293 let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes);
1294 let board_actions = edit_tip.as_ref().map(|tip| todomd::BoardActions {
1295 owner: &owner,
1296 repo: &repo,
1297 rev: &rev,
1298 path: &path,
1299 tip,
1300 csrf: &csrf.0,
1301 });
1302 let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md"))
1303 .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes), board_actions.as_ref()))
1304 .flatten();
1305 let rendered = markdown && !query.contains_key("plain") && board.is_none();
1306
1307 let body = if let Some(board) = &board {
1308 board.clone()
1309 } else if is_binary(&bytes) {
1310 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
1311 } else if rendered {
1312 let text = String::from_utf8_lossy(&bytes);
1313 html! { div.md-body { (render_markdown(&text)) } }
1314 } else {
1315 let text = String::from_utf8_lossy(&bytes);
1316 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
1317 let lines = cached_highlight(budget, &oid, &path, &text);
1318 html! {
1319 table.code {
1320 @for (i, line) in lines.iter().enumerate() {
1321 tr {
1322 td.ln { (i + 1) }
1323 td { (PreEscaped(line)) }
1324 }
1325 }
1326 }
1327 }
1328 };
1329
1330 let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
1331 Ok(layout(
1332 &format!("{owner}/{repo}: {path}"),
1333 user.as_ref(),
1334 html! {
1335 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1336 (breadcrumbs(&owner, &repo, &rev, &path, true))
1337 @if can_edit {
1338 p.file-actions {
1339 a.btn.btn-secondary href=(format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(&rev))) {
1340 (icon(Icon::Pencil)) "Edit"
1341 }
1342 @if is_todo {
1343 a.btn.btn-secondary href=(format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(&rev))) {
1344 (icon(Icon::Plus)) "Add task"
1345 }
1346 }
1347 }
1348 }
1349 @if markdown {
1350 p.view-toggle {
1351 span.pill-group {
1352 @if is_todo {
1353 @if board.is_some() { span.pill.active { "Board" } }
1354 @else { a.pill href=(&blob_url) { "Board" } }
1355 @if rendered { span.pill.active { "Rendered" } }
1356 @else { a.pill href=(format!("{blob_url}?md=1")) { "Rendered" } }
1357 } @else if rendered {
1358 span.pill.active { "Rendered" }
1359 } @else {
1360 a.pill href=(&blob_url) { "Rendered" }
1361 }
1362 @if rendered || board.is_some() {
1363 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
1364 } @else {
1365 span.pill.active { "Source" }
1366 }
1367 }
1368 }
1369 }
1370 @if board.is_some() {
1371 // The board supplies its own column structure; an enclosing
1372 // box would just nest frames.
1373 (body)
1374 } @else {
1375 div.box style="overflow-x:auto" { (body) }
1376 }
1377 },
1378 ))
1379}
1380
1381#[derive(serde::Deserialize)]
1382struct EditFileForm {
1383 csrf: String,
1384 /// Expected branch tip the editor saw — the compare-and-swap guard.
1385 expected_tip: String,
1386 message: String,
1387 content: String,
1388}
1389
1390/// Resolve a repo for a web edit, enforcing read+write access and that `rev`
1391/// names a branch (editing advances a branch ref). Returns the repo path and
1392/// the branch tip the editor is working from.
1393async fn resolve_for_edit(
1394 app: &App,
1395 user: Option<&User>,
1396 owner: &str,
1397 repo: &str,
1398 rev: &str,
1399) -> Result<(PathBuf, String), Response> {
1400 let (repo_path, meta) = resolve_repo(app, user, owner, repo).await?;
1401 if user.is_none() {
1402 return Err(Redirect::to("/-/login").into_response());
1403 }
1404 if !access::can_write(&meta, user) {
1405 return Err(forbidden());
1406 }
1407 let tip = browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}"))
1408 .map_err(|_| not_found("not an editable branch"))?;
1409 Ok((repo_path, tip))
1410}
1411
1412/// `GET /{owner}/{repo}/edit/{rev}/{*path}` — textarea editor for an existing
1413/// text file on a branch.
1414async fn edit_form(
1415 State(app): State<App>,
1416 CurrentUser(user): CurrentUser,
1417 csrf: Csrf,
1418 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1419) -> Response {
1420 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1421 Ok(v) => v,
1422 Err(resp) => return resp,
1423 };
1424 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1425 Ok(Some(b)) => b,
1426 Ok(None) => return not_found("file not found"),
1427 Err(e) => return server_error(e),
1428 };
1429 if is_binary(&bytes) {
1430 return bad_request_page(
1431 user.as_ref(),
1432 "Binary files can't be edited in the browser.",
1433 );
1434 }
1435 let content = String::from_utf8_lossy(&bytes).into_owned();
1436 edit_page(
1437 &owner,
1438 &repo,
1439 &rev,
1440 &path,
1441 &content,
1442 &format!("Update {path}"),
1443 &tip,
1444 None,
1445 user.as_ref(),
1446 &csrf.0,
1447 )
1448 .into_response()
1449}
1450
1451/// `POST /{owner}/{repo}/edit/{rev}/{*path}` — commit the edited content.
1452async fn edit_submit(
1453 State(app): State<App>,
1454 CurrentUser(user): CurrentUser,
1455 csrf: Csrf,
1456 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1457 Form(form): Form<EditFileForm>,
1458) -> Response {
1459 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1460 Ok((p, _)) => p,
1461 Err(resp) => return resp,
1462 };
1463 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1464 return resp;
1465 }
1466 let user = user.expect("resolve_for_edit requires a logged-in user");
1467
1468 // Browsers serialize textarea newlines as CRLF; normalize so an edit
1469 // doesn't rewrite every line ending.
1470 let content = form.content.replace("\r\n", "\n");
1471 let message = if form.message.trim().is_empty() {
1472 format!("Update {path}")
1473 } else {
1474 form.message.clone()
1475 };
1476
1477 match anvil_git::edit::commit_file_change(
1478 &repo_path,
1479 &rev,
1480 &form.expected_tip,
1481 &path,
1482 content.as_bytes(),
1483 &user.username,
1484 &user.email,
1485 &message,
1486 ) {
1487 Ok(_) => {
1488 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1489 }
1490 Err(e) => edit_page(
1491 &owner,
1492 &repo,
1493 &rev,
1494 &path,
1495 &content,
1496 &message,
1497 &form.expected_tip,
1498 Some(&e.to_string()),
1499 Some(&user),
1500 &csrf.0,
1501 )
1502 .into_response(),
1503 }
1504}
1505
1506/// The file-editor page: a textarea, a commit-message field, and the
1507/// compare-and-swap tip carried in a hidden field.
1508#[allow(clippy::too_many_arguments)]
1509fn edit_page(
1510 owner: &str,
1511 repo: &str,
1512 rev: &str,
1513 path: &str,
1514 content: &str,
1515 message: &str,
1516 expected_tip: &str,
1517 error: Option<&str>,
1518 user: Option<&User>,
1519 csrf: &str,
1520) -> Markup {
1521 let action = format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(rev));
1522 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1523 let upload_url = format!("/{owner}/{repo}/-/attachments");
1524 layout(
1525 &format!("Edit {path}"),
1526 user,
1527 html! {
1528 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1529 (breadcrumbs(owner, repo, rev, path, true))
1530 p.muted { "Editing on branch " code { (rev) } " — commits as you." }
1531 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1532 form.stack method="post" action=(action) {
1533 (csrf_input(csrf))
1534 input type="hidden" name="expected_tip" value=(expected_tip);
1535 p {
1536 textarea.editor name="content" rows="24" spellcheck="false" autofocus
1537 data-upload-url=(upload_url) data-csrf=(csrf) { (content) }
1538 }
1539 p.upload-hint {
1540 label.btn.btn-secondary.attach-btn {
1541 "Attach image"
1542 input.attach-input type="file" accept="image/*" multiple hidden;
1543 }
1544 " "
1545 span.muted { "or paste/drop one — it's stored outside git and a Markdown link is inserted." }
1546 }
1547 p { label { "Commit message" br; input type="text" name="message" value=(message); } }
1548 p {
1549 button.btn type="submit" { "Commit changes" }
1550 " "
1551 a.btn.btn-secondary href=(cancel) { "Cancel" }
1552 }
1553 }
1554 script { (PreEscaped(EDITOR_JS)) }
1555 },
1556 )
1557}
1558
1559/// Paste/drop-to-upload for the file editor: image clipboard items and dropped
1560/// image files are POSTed to the repo's attachment endpoint as a raw body, and
1561/// the returned Markdown is spliced into the textarea at the cursor. The blob
1562/// is stored outside git; only the URL lands in the file.
1563const EDITOR_JS: &str = r#"
1564(function(){
1565 var ta = document.querySelector('textarea.editor');
1566 if (!ta || !ta.dataset.uploadUrl) return;
1567 var url = ta.dataset.uploadUrl, csrf = ta.dataset.csrf;
1568 function insertAtCursor(text){
1569 var s = ta.selectionStart, e = ta.selectionEnd;
1570 ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
1571 ta.selectionStart = ta.selectionEnd = s + text.length;
1572 ta.focus();
1573 }
1574 function replaceFirst(find, repl){
1575 var i = ta.value.indexOf(find);
1576 if (i >= 0) ta.value = ta.value.slice(0, i) + repl + ta.value.slice(i + find.length);
1577 }
1578 function upload(file){
1579 var token = '![uploading ' + (file.name || 'image') + '…]()';
1580 insertAtCursor(token + '\n');
1581 fetch(url, {
1582 method: 'POST',
1583 headers: {'X-CSRF-Token': csrf, 'Content-Type': file.type || 'application/octet-stream'},
1584 body: file
1585 }).then(function(r){
1586 if (!r.ok) throw new Error('upload failed (' + r.status + ')');
1587 return r.json();
1588 }).then(function(d){
1589 replaceFirst(token, d.markdown);
1590 }).catch(function(err){
1591 replaceFirst(token, '![upload failed]()');
1592 console.error(err);
1593 });
1594 }
1595 ta.addEventListener('paste', function(ev){
1596 var items = (ev.clipboardData || {}).items || [];
1597 for (var i = 0; i < items.length; i++){
1598 if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0){
1599 ev.preventDefault();
1600 upload(items[i].getAsFile());
1601 }
1602 }
1603 });
1604 ta.addEventListener('dragover', function(ev){ ev.preventDefault(); });
1605 ta.addEventListener('drop', function(ev){
1606 var files = (ev.dataTransfer || {}).files || [], imgs = [];
1607 for (var i = 0; i < files.length; i++){
1608 if (files[i].type.indexOf('image/') === 0) imgs.push(files[i]);
1609 }
1610 if (imgs.length){ ev.preventDefault(); imgs.forEach(upload); }
1611 });
1612 // The "Attach image" button (works where paste/drop don't, e.g. mobile):
1613 // a file picker that uploads each chosen image.
1614 var picker = document.querySelector('input.attach-input');
1615 if (picker) picker.addEventListener('change', function(){
1616 var files = picker.files || [];
1617 for (var i = 0; i < files.length; i++){
1618 if (files[i].type.indexOf('image/') === 0) upload(files[i]);
1619 }
1620 picker.value = ''; // let the same file be re-picked
1621 });
1622})();
1623"#;
1624
1625#[derive(serde::Deserialize)]
1626struct AddTaskForm {
1627 csrf: String,
1628 expected_tip: String,
1629 section: String,
1630 title: String,
1631 #[serde(default)]
1632 body: String,
1633}
1634
1635/// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form
1636/// for a `TODO.md`, appending a `- [ ]` item per the todo-md round-trip rules.
1637async fn add_task_form(
1638 State(app): State<App>,
1639 CurrentUser(user): CurrentUser,
1640 csrf: Csrf,
1641 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1642) -> Response {
1643 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1644 Ok(v) => v,
1645 Err(resp) => return resp,
1646 };
1647 if !todomd::is_todo_md(&path) {
1648 return not_found("not a TODO.md");
1649 }
1650 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1651 Ok(Some(b)) => b,
1652 Ok(None) => return not_found("file not found"),
1653 Err(e) => return server_error(e),
1654 };
1655 let sections = todomd::task_sections(&String::from_utf8_lossy(&bytes));
1656 if sections.is_empty() {
1657 return bad_request_page(user.as_ref(), "This TODO.md has no sections to add to.");
1658 }
1659 add_task_page(
1660 &owner,
1661 &repo,
1662 &rev,
1663 &path,
1664 &sections,
1665 "",
1666 "",
1667 &tip,
1668 None,
1669 user.as_ref(),
1670 &csrf.0,
1671 )
1672 .into_response()
1673}
1674
1675/// `POST /{owner}/{repo}/add-task/{rev}/{*path}` — append the task and commit.
1676async fn add_task_submit(
1677 State(app): State<App>,
1678 CurrentUser(user): CurrentUser,
1679 csrf: Csrf,
1680 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1681 Form(form): Form<AddTaskForm>,
1682) -> Response {
1683 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1684 Ok((p, _)) => p,
1685 Err(resp) => return resp,
1686 };
1687 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1688 return resp;
1689 }
1690 let user = user.expect("resolve_for_edit requires a logged-in user");
1691 if !todomd::is_todo_md(&path) {
1692 return not_found("not a TODO.md");
1693 }
1694 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1695 Ok(Some(b)) => b,
1696 Ok(None) => return not_found("file not found"),
1697 Err(e) => return server_error(e),
1698 };
1699 let text = String::from_utf8_lossy(&bytes);
1700 let sections = todomd::task_sections(&text);
1701
1702 // Browsers serialize textarea newlines as CRLF; store LF.
1703 let body = form.body.replace("\r\n", "\n");
1704
1705 let render_err = |msg: &str, csrf: &Csrf| {
1706 add_task_page(
1707 &owner,
1708 &repo,
1709 &rev,
1710 &path,
1711 &sections,
1712 &form.title,
1713 &body,
1714 &form.expected_tip,
1715 Some(msg),
1716 Some(&user),
1717 &csrf.0,
1718 )
1719 .into_response()
1720 };
1721
1722 let Some(updated) = todomd::add_task(&text, &form.section, &form.title, &body) else {
1723 return render_err(
1724 "Couldn't add the task — check the title isn't empty and the section exists.",
1725 &csrf,
1726 );
1727 };
1728
1729 let message = format!("Add task to {}", form.section);
1730 match anvil_git::edit::commit_file_change(
1731 &repo_path,
1732 &rev,
1733 &form.expected_tip,
1734 &path,
1735 updated.as_bytes(),
1736 &user.username,
1737 &user.email,
1738 &message,
1739 ) {
1740 Ok(_) => {
1741 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1742 }
1743 Err(e) => render_err(&e.to_string(), &csrf),
1744 }
1745}
1746
1747#[derive(serde::Deserialize)]
1748struct DeleteTaskForm {
1749 #[serde(default)]
1750 csrf: String,
1751 expected_tip: String,
1752 section: String,
1753 title: String,
1754}
1755
1756/// `POST /{owner}/{repo}/delete-task/{rev}/{*path}` — remove a task/ticket from
1757/// a `TODO.md` (the ✕ on a board card) and commit. Compare-and-swap guarded by
1758/// `expected_tip`, so a concurrent change is rejected rather than clobbered.
1759async fn delete_task(
1760 State(app): State<App>,
1761 CurrentUser(user): CurrentUser,
1762 csrf: Csrf,
1763 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1764 Form(form): Form<DeleteTaskForm>,
1765) -> Response {
1766 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1767 Ok((p, _)) => p,
1768 Err(resp) => return resp,
1769 };
1770 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1771 return resp;
1772 }
1773 let user = user.expect("resolve_for_edit requires a logged-in user");
1774 if !todomd::is_todo_md(&path) {
1775 return not_found("not a TODO.md");
1776 }
1777 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1778 Ok(Some(b)) => b,
1779 Ok(None) => return not_found("file not found"),
1780 Err(e) => return server_error(e),
1781 };
1782 let text = String::from_utf8_lossy(&bytes);
1783
1784 let Some(updated) = todomd::remove_task(&text, &form.section, &form.title) else {
1785 // Already gone (e.g. a double submit) — just show the current board.
1786 return Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev)))
1787 .into_response();
1788 };
1789
1790 let message = format!("Delete task: {}", form.title);
1791 match anvil_git::edit::commit_file_change(
1792 &repo_path,
1793 &rev,
1794 &form.expected_tip,
1795 &path,
1796 updated.as_bytes(),
1797 &user.username,
1798 &user.email,
1799 &message,
1800 ) {
1801 Ok(_) => {
1802 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1803 }
1804 Err(e) => bad_request_page(Some(&user), &format!("Couldn't delete the task: {e}")),
1805 }
1806}
1807
1808#[derive(serde::Deserialize)]
1809struct MoveTaskForm {
1810 #[serde(default)]
1811 csrf: String,
1812 expected_tip: String,
1813 title: String,
1814 from_section: String,
1815 to_section: String,
1816 to_index: usize,
1817}
1818
1819/// `POST /{owner}/{repo}/move-task/{rev}/{*path}` — reorder/move a task on the
1820/// board (drag-and-drop). Write-gated, CSRF-checked, compare-and-swap on the
1821/// branch tip. Driven by `fetch`, so it returns bare status codes.
1822async fn move_task(
1823 State(app): State<App>,
1824 CurrentUser(user): CurrentUser,
1825 csrf: Csrf,
1826 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1827 Form(form): Form<MoveTaskForm>,
1828) -> Response {
1829 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1830 Ok((p, _)) => p,
1831 Err(resp) => return resp,
1832 };
1833 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1834 return resp;
1835 }
1836 let user = user.expect("resolve_for_edit requires a logged-in user");
1837 if !todomd::is_todo_md(&path) {
1838 return not_found("not a TODO.md");
1839 }
1840 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1841 Ok(Some(b)) => b,
1842 Ok(None) => return not_found("file not found"),
1843 Err(e) => return server_error(e),
1844 };
1845 let text = String::from_utf8_lossy(&bytes);
1846
1847 let Some(updated) = todomd::move_task(
1848 &text,
1849 &form.title,
1850 &form.from_section,
1851 &form.to_section,
1852 form.to_index,
1853 ) else {
1854 return (StatusCode::BAD_REQUEST, "could not move task").into_response();
1855 };
1856
1857 let message = if form.from_section == form.to_section {
1858 format!("Reorder {} in {}", form.title, form.to_section)
1859 } else {
1860 format!("Move {} to {}", form.title, form.to_section)
1861 };
1862 match anvil_git::edit::commit_file_change(
1863 &repo_path,
1864 &rev,
1865 &form.expected_tip,
1866 &path,
1867 updated.as_bytes(),
1868 &user.username,
1869 &user.email,
1870 &message,
1871 ) {
1872 // A no-op drop (dropped back in place) is success, not an error.
1873 Ok(_) | Err(anvil_git::edit::EditError::NoChanges) => StatusCode::OK.into_response(),
1874 Err(anvil_git::edit::EditError::BranchMoved { .. }) => {
1875 (StatusCode::CONFLICT, "branch moved — reload").into_response()
1876 }
1877 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
1878 }
1879}
1880
1881/// The add-task form: a section dropdown, a title field, and a Markdown
1882/// description (which supports paste/drop image upload, like the file editor).
1883#[allow(clippy::too_many_arguments)]
1884fn add_task_page(
1885 owner: &str,
1886 repo: &str,
1887 rev: &str,
1888 path: &str,
1889 sections: &[String],
1890 title: &str,
1891 body: &str,
1892 expected_tip: &str,
1893 error: Option<&str>,
1894 user: Option<&User>,
1895 csrf: &str,
1896) -> Markup {
1897 let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev));
1898 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1899 let upload_url = format!("/{owner}/{repo}/-/attachments");
1900 layout(
1901 &format!("Add task · {path}"),
1902 user,
1903 html! {
1904 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1905 (breadcrumbs(owner, repo, rev, path, true))
1906 h2 { "Add a task" }
1907 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1908 form.stack method="post" action=(action) {
1909 (csrf_input(csrf))
1910 input type="hidden" name="expected_tip" value=(expected_tip);
1911 p { label { "Section" br;
1912 select name="section" {
1913 @for s in sections { option value=(s) { (s) } }
1914 }
1915 } }
1916 p { label { "Title" br;
1917 input type="text" name="title" value=(title) placeholder="Short ticket title" autofocus;
1918 } }
1919 p { label { "Description" br;
1920 textarea.editor name="body" rows="10" spellcheck="false"
1921 placeholder="Markdown — attach an image with the button below, or paste/drop one"
1922 data-upload-url=(upload_url) data-csrf=(csrf) { (body) }
1923 } }
1924 p.upload-hint {
1925 label.btn.btn-secondary.attach-btn {
1926 "Attach image"
1927 input.attach-input type="file" accept="image/*" multiple hidden;
1928 }
1929 " "
1930 span.muted { "stored outside git; a Markdown link is inserted into the description." }
1931 }
1932 p {
1933 button.btn type="submit" { "Add task" }
1934 " "
1935 a.btn.btn-secondary href=(cancel) { "Cancel" }
1936 }
1937 }
1938 script { (PreEscaped(EDITOR_JS)) }
1939 },
1940 )
1941}
1942
1943/// A 400 page for malformed edit requests (binary file, no sections, …).
1944fn bad_request_page(user: Option<&User>, message: &str) -> Response {
1945 (
1946 StatusCode::BAD_REQUEST,
1947 layout(
1948 "Can't edit",
1949 user,
1950 html! { h1 { "Can't edit" } p.muted { (message) } },
1951 ),
1952 )
1953 .into_response()
1954}
1955
1956/// Pick the singular or plural noun for a count (`1 branch` / `2 branches`).
1957fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
1958 if n == 1 { one } else { many }
1959}
1960
1961/// Whether a path should be treated as markdown (by extension).
1962fn is_markdown(path: &str) -> bool {
1963 std::path::Path::new(path)
1964 .extension()
1965 .and_then(|e| e.to_str())
1966 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
1967}
1968
1969/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
1970///
1971/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
1972/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
1973/// link and image destinations are dropped.
1974pub(crate) fn render_markdown(text: &str) -> Markup {
1975 use pulldown_cmark::{
1976 Event,
1977 Options,
1978 Parser,
1979 Tag,
1980 html,
1981 };
1982
1983 fn safe_url(dest: &str) -> bool {
1984 let d = dest.trim().to_ascii_lowercase();
1985 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
1986 }
1987
1988 let opts = Options::ENABLE_TABLES
1989 | Options::ENABLE_STRIKETHROUGH
1990 | Options::ENABLE_TASKLISTS
1991 | Options::ENABLE_FOOTNOTES;
1992 let events = Parser::new_ext(text, opts).map(|ev| match ev {
1993 Event::Html(h) => Event::Text(h),
1994 Event::InlineHtml(h) => Event::Text(h),
1995 Event::Start(Tag::Link {
1996 link_type,
1997 dest_url,
1998 title,
1999 id,
2000 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
2001 link_type,
2002 dest_url: "".into(),
2003 title,
2004 id,
2005 }),
2006 Event::Start(Tag::Image {
2007 link_type,
2008 dest_url,
2009 title,
2010 id,
2011 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
2012 link_type,
2013 dest_url: "".into(),
2014 title,
2015 id,
2016 }),
2017 e => e,
2018 });
2019 let mut out = String::new();
2020 html::push_html(&mut out, events);
2021 PreEscaped(out)
2022}
2023
2024/// Render a language breakdown bar showing percentages of each detected language.
2025/// Displays as a horizontal bar with each language's proportion.
2026pub(crate) fn render_languages_bar(languages_json: &str) -> Option<Markup> {
2027 if languages_json.is_empty() || languages_json == "[]" {
2028 return None;
2029 }
2030
2031 // Parse the JSON array
2032 let langs: Vec<serde_json::Value> = serde_json::from_str(languages_json).ok()?;
2033 if langs.is_empty() {
2034 return None;
2035 }
2036
2037 // Color palette for languages (simple heuristic)
2038 let color_for_lang = |lang: &str| -> &'static str {
2039 match lang {
2040 "Rust" => "#CE422B",
2041 "Python" => "#3776AB",
2042 "JavaScript" => "#F7DF1E",
2043 "TypeScript" => "#3178C6",
2044 "Go" => "#00ADD8",
2045 "Java" => "#007396",
2046 "C++" => "#00599C",
2047 "C#" => "#239120",
2048 "Ruby" => "#CC342D",
2049 "PHP" => "#777BB4",
2050 "Markdown" => "#083FA1",
2051 "HTML" => "#E34C26",
2052 "CSS" => "#563D7C",
2053 "SQL" => "#336791",
2054 _ => "#999999",
2055 }
2056 };
2057
2058 let mut html = String::from(
2059 r#"<div class="language-bar" style="display:flex;border-radius:4px;overflow:hidden;height:20px;background:#f0f0f0;">"#,
2060 );
2061 for lang_obj in langs {
2062 if let (Some(lang), Some(percent)) = (
2063 lang_obj.get("lang").and_then(|v| v.as_str()),
2064 lang_obj.get("percent").and_then(|v| v.as_f64()),
2065 ) {
2066 let color = color_for_lang(lang);
2067 html.push_str(&format!(
2068 r#"<div style="width:{:.1}%;background-color:{};tooltip:'{}';height:100%" title="{}"></div>"#,
2069 percent, color, lang, lang
2070 ));
2071 }
2072 }
2073 html.push_str("</div>");
2074
2075 Some(PreEscaped(html))
2076}
2077
2078/// How far back the per-entry "latest commit" walk looks. Entries last touched
2079/// beyond this many commits just lose the annotation.
2080const ENTRY_LOG_WALK: usize = 400;
2081
2082/// Folder or file icon for an entry row (tree listings, pages, artifacts).
2083pub(crate) fn entry_icon(is_dir: bool) -> Markup {
2084 if is_dir {
2085 icon_with(Icon::Folder, "icon dir")
2086 } else {
2087 icon(Icon::File)
2088 }
2089}
2090
2091/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
2092pub(crate) fn fmt_size(bytes: i64) -> String {
2093 let b = bytes.max(0) as f64;
2094 match b {
2095 b if b < 1024.0 => format!("{bytes} B"),
2096 b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
2097 b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
2098 b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
2099 }
2100}
2101
2102/// Percent-encode a ref name for use as one path segment in a URL. Axum
2103/// matches routes before decoding, so an encoded `/` keeps a branch like
2104/// `feat/x` inside the single `{rev}` segment.
2105pub(crate) fn enc_ref(name: &str) -> String {
2106 name.replace('%', "%25")
2107 .replace('/', "%2F")
2108 .replace('?', "%3F")
2109 .replace('#', "%23")
2110}
2111
2112/// Branch/tag switcher: a dropdown over the current rev linking each ref to
2113/// its tree view. Branch names, tag names, and commit ids all work as `rev`.
2114fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup {
2115 html! {
2116 details.nav-menu.rev-menu {
2117 summary { span.pill { (rev) } }
2118 div.nav-dropdown.left {
2119 @if !overview.branches.is_empty() {
2120 div.dd-head { "Branches" }
2121 @for b in &overview.branches {
2122 a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) }
2123 }
2124 }
2125 @if !overview.tags.is_empty() {
2126 div.dd-head { "Tags" }
2127 @for t in &overview.tags {
2128 a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) }
2129 }
2130 }
2131 }
2132 }
2133 }
2134}
2135
2136/// Render a tree listing as a box of rows; directories link to `tree`, files to
2137/// `blob`. Each entry also shows the subject of (and links to) the latest
2138/// commit that touched it, when `latest` has one for it.
2139fn tree_table(
2140 owner: &str,
2141 repo: &str,
2142 rev: &str,
2143 path: &str,
2144 entries: &[browse::TreeEntry],
2145 latest: &BTreeMap<String, browse::CommitInfo>,
2146) -> Markup {
2147 let join = |name: &str| {
2148 if path.is_empty() {
2149 name.to_string()
2150 } else {
2151 format!("{path}/{name}")
2152 }
2153 };
2154 html! {
2155 div.box {
2156 @if !path.is_empty() {
2157 div.row {
2158 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
2159 }
2160 }
2161 @for e in entries {
2162 @let child = join(&e.name);
2163 @let kind = if e.is_dir { "tree" } else { "blob" };
2164 div.row {
2165 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
2166 (entry_icon(e.is_dir))
2167 (e.name) @if e.is_dir { "/" }
2168 }
2169 @if let Some(c) = latest.get(&e.name) {
2170 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
2171 span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
2172 }
2173 }
2174 }
2175 }
2176 }
2177}
2178
2179fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
2180 match path.rsplit_once('/') {
2181 Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)),
2182 None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)),
2183 }
2184}
2185
2186/// Path breadcrumbs. `is_blob` marks the final component as a file.
2187fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
2188 // Precompute (label, cumulative_path) for each path component.
2189 let mut crumbs: Vec<(String, String)> = Vec::new();
2190 let mut acc = String::new();
2191 for part in path.split('/').filter(|p| !p.is_empty()) {
2192 if !acc.is_empty() {
2193 acc.push('/');
2194 }
2195 acc.push_str(part);
2196 crumbs.push((part.to_string(), acc.clone()));
2197 }
2198 let last = crumbs.len();
2199 html! {
2200 div.crumbs {
2201 a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
2202 @for (i, (label, cum)) in crumbs.iter().enumerate() {
2203 " / "
2204 @if i + 1 == last && is_blob {
2205 span { (label) }
2206 } @else {
2207 a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
2208 }
2209 }
2210 }
2211 }
2212}
2213
2214/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
2215async fn commits(
2216 State(app): State<App>,
2217 CurrentUser(user): CurrentUser,
2218 Path((owner, repo, rev)): Path<(String, String, String)>,
2219) -> Result<Markup, Response> {
2220 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2221 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
2222
2223 // Map each commit oid to its latest run status, for inline badges. One query
2224 // for the repo's recent runs; first match wins (list is newest-first).
2225 let runs = ci::list_by_repo(&app.db, meta.id, 200)
2226 .await
2227 .unwrap_or_default();
2228 let mut status_of: HashMap<&str, &str> = HashMap::new();
2229 for r in &runs {
2230 status_of
2231 .entry(r.commit.as_str())
2232 .or_insert(r.status.as_str());
2233 }
2234
2235 Ok(layout(
2236 &format!("{owner}/{repo}: commits"),
2237 user.as_ref(),
2238 html! {
2239 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
2240 ul.commit-list {
2241 @for c in &log {
2242 li {
2243 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
2244 @if let Some(st) = status_of.get(c.id.as_str()) {
2245 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
2246 }
2247 span { (c.summary) }
2248 span.muted style="margin-left:auto" {
2249 (c.author) " · "
2250 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
2251 }
2252 }
2253 }
2254 }
2255 },
2256 ))
2257}
2258
2259/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
2260async fn commit(
2261 State(app): State<App>,
2262 CurrentUser(user): CurrentUser,
2263 Path((owner, repo, id)): Path<(String, String, String)>,
2264) -> Result<Markup, Response> {
2265 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2266 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
2267 Ok(layout(
2268 &format!("{owner}/{repo}: {}", detail.info.short),
2269 user.as_ref(),
2270 html! {
2271 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
2272 p { (detail.info.summary) }
2273 p.muted {
2274 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
2275 span.sha { (detail.info.id) }
2276 @if let Some(parent) = &detail.parent {
2277 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
2278 }
2279 " · "
2280 a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
2281 }
2282 @if detail.changes.is_empty() {
2283 p.muted { "No file changes." }
2284 }
2285 @for change in &detail.changes {
2286 (render_file_diff(change))
2287 }
2288 },
2289 ))
2290}
2291
2292/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
2293async fn ci_runs(
2294 State(app): State<App>,
2295 CurrentUser(user): CurrentUser,
2296 Path((owner, repo)): Path<(String, String)>,
2297) -> Result<Markup, Response> {
2298 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2299 let runs = ci::list_by_repo(&app.db, meta.id, 100)
2300 .await
2301 .map_err(server_error)?;
2302 Ok(layout(
2303 &format!("{owner}/{repo}: CI"),
2304 user.as_ref(),
2305 html! {
2306 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
2307 @if runs.is_empty() {
2308 p.muted {
2309 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
2310 " pipeline and push to trigger one."
2311 }
2312 } @else {
2313 div.box {
2314 @for r in &runs {
2315 div.row {
2316 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
2317 (status_badge(&r.status))
2318 span.sha { (short_commit(&r.commit)) }
2319 span { (r.ref_name) }
2320 }
2321 span.muted { (fmt_time(r.created_at)) }
2322 }
2323 }
2324 }
2325 }
2326 },
2327 ))
2328}
2329
2330/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
2331async fn ci_run(
2332 State(app): State<App>,
2333 CurrentUser(user): CurrentUser,
2334 Path((owner, repo, id)): Path<(String, String, i64)>,
2335) -> Result<Markup, Response> {
2336 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
2337 let run = ci::get(&app.db, id)
2338 .await
2339 .map_err(server_error)?
2340 .filter(|r| r.repo_id == meta.id)
2341 .ok_or_else(|| not_found("no such CI run"))?;
2342 let artifacts = ci::artifacts_for_run(&app.db, run.id)
2343 .await
2344 .map_err(server_error)?;
2345 Ok(layout(
2346 &format!("{owner}/{repo}: CI #{}", run.id),
2347 user.as_ref(),
2348 html! {
2349 h1 {
2350 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
2351 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
2352 " · #" (run.id)
2353 }
2354 p {
2355 (status_badge(&run.status))
2356 " "
2357 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
2358 " " span.muted { (run.ref_name) }
2359 }
2360 p.muted {
2361 "queued " (fmt_time(run.created_at))
2362 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
2363 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
2364 @if let Some(d) = run_duration(&run) { " · took " (d) }
2365 }
2366 @if !artifacts.is_empty() {
2367 h2 { "Artifacts" }
2368 div.box {
2369 @for a in &artifacts {
2370 div.row {
2371 a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
2372 (entry_icon(a.is_dir))
2373 (a.name)
2374 @if a.browse { " " span.pill { "site" } }
2375 @else if a.is_dir { ".tar.gz" }
2376 }
2377 span.muted {
2378 (artifact_meta_chips(&a.meta))
2379 (fmt_size(a.size))
2380 }
2381 }
2382 }
2383 }
2384 }
2385 @if run.log.is_empty() {
2386 p.muted { "No output yet." }
2387 } @else {
2388 pre.log { (run.log) }
2389 }
2390 },
2391 ))
2392}
2393
2394/// Render an artifact's extractor metadata (a JSON object of key → value) as
2395/// inline `key: value` chips before the size.
2396fn artifact_meta_chips(meta: &str) -> Markup {
2397 let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
2398 html! {
2399 @for (k, v) in &map {
2400 span.pill title=(k) { (k) ": " (v) }
2401 " "
2402 }
2403 }
2404}
2405
2406/// A coloured status pill for a CI run status string.
2407fn status_badge(status: &str) -> Markup {
2408 html! { span class=(format!("st {status}")) { (status) } }
2409}
2410
2411/// First 8 hex chars of a commit oid (for compact display).
2412fn short_commit(commit: &str) -> &str {
2413 &commit[..commit.len().min(8)]
2414}
2415
2416/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
2417fn run_duration(run: &CiRun) -> Option<String> {
2418 if run.started_at > 0 && run.finished_at >= run.started_at {
2419 Some(format!("{}s", run.finished_at - run.started_at))
2420 } else {
2421 None
2422 }
2423}
2424
2425/// Render one file's diff (added/deleted/modified) as a unified line diff.
2426/// A file diff bigger than this many rows starts collapsed (its header still
2427/// shows the +/− counts; clicking expands it — native `details`, no JS).
2428const DIFF_COLLAPSE_ROWS: usize = 400;
2429
2430fn render_file_diff(change: &FileChange) -> Markup {
2431 let (badge_cls, badge) = match change.kind {
2432 ChangeKind::Added => ("add", "added"),
2433 ChangeKind::Deleted => ("del", "deleted"),
2434 ChangeKind::Modified => ("mod", "modified"),
2435 };
2436 let head = |stat: Markup| {
2437 html! {
2438 summary.head {
2439 span class=(format!("badge {badge_cls}")) { (badge) }
2440 span { (change.path) }
2441 span.stat { (stat) }
2442 }
2443 }
2444 };
2445
2446 let binary = change.old.as_deref().is_some_and(is_binary)
2447 || change.new.as_deref().is_some_and(is_binary);
2448 if binary {
2449 return html! {
2450 details.file-diff open {
2451 (head(html! { span.muted { "binary" } }))
2452 div.box { div.row { span.muted { "Binary file" } } }
2453 }
2454 };
2455 }
2456
2457 let old = change
2458 .old
2459 .as_deref()
2460 .map(|b| String::from_utf8_lossy(b).into_owned())
2461 .unwrap_or_default();
2462 let new = change
2463 .new
2464 .as_deref()
2465 .map(|b| String::from_utf8_lossy(b).into_owned())
2466 .unwrap_or_default();
2467 let diff = TextDiff::from_lines(&old, &new);
2468 let (mut adds, mut dels) = (0usize, 0usize);
2469 for c in diff.iter_all_changes() {
2470 match c.tag() {
2471 ChangeTag::Insert => adds += 1,
2472 ChangeTag::Delete => dels += 1,
2473 ChangeTag::Equal => {}
2474 }
2475 }
2476 // Hunks: changed lines plus 3 lines of context, not the whole file.
2477 let groups = diff.grouped_ops(3);
2478 let rendered_rows: usize = groups
2479 .iter()
2480 .flatten()
2481 .map(|op| diff.iter_changes(op).count())
2482 .sum();
2483
2484 html! {
2485 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
2486 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
2487 (diff_table(&diff, &groups, old.lines().count()))
2488 }
2489 }
2490}
2491
2492/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
2493/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
2494/// (including before the first hunk and after the last).
2495fn diff_table<'a>(
2496 diff: &TextDiff<'a, 'a, '_, str>,
2497 groups: &[Vec<similar::DiffOp>],
2498 old_total: usize,
2499) -> Markup {
2500 let gap_row = |n: usize| {
2501 html! {
2502 @if n > 0 {
2503 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
2504 }
2505 }
2506 };
2507 // Unchanged-line gap before each group, and after the last one.
2508 let mut prev_end = 0usize; // end of the previous group, in old-file lines
2509 let mut with_gaps = Vec::with_capacity(groups.len());
2510 for group in groups {
2511 let start = group.first().map_or(prev_end, |op| op.old_range().start);
2512 with_gaps.push((start.saturating_sub(prev_end), group));
2513 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
2514 }
2515 let trailing = old_total.saturating_sub(prev_end);
2516
2517 html! {
2518 table.code.diff {
2519 @for (gap, group) in &with_gaps {
2520 (gap_row(*gap))
2521 @for op in group.iter() {
2522 @for change in diff.iter_changes(op) {
2523 @let (sign, cls) = match change.tag() {
2524 ChangeTag::Delete => ("-", "del"),
2525 ChangeTag::Insert => ("+", "ins"),
2526 ChangeTag::Equal => (" ", ""),
2527 };
2528 tr class=(cls) {
2529 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
2530 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
2531 td.sign { (sign) }
2532 td { (change.value().trim_end_matches('\n')) }
2533 }
2534 }
2535 }
2536 }
2537 (gap_row(trailing))
2538 }
2539 }
2540}
2541
2542/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
2543fn highlighter() -> &'static (SyntaxSet, Theme) {
2544 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
2545 HL.get_or_init(|| {
2546 let syntaxes = SyntaxSet::load_defaults_newlines();
2547 let themes = ThemeSet::load_defaults();
2548 let theme = themes
2549 .themes
2550 .get("InspiredGitHub")
2551 .or_else(|| themes.themes.values().next())
2552 .cloned()
2553 .expect("at least one default theme");
2554 (syntaxes, theme)
2555 })
2556}
2557
2558/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
2559/// blob's rendered HTML is immutable for its object id (the extension is part
2560/// of the key because it picks the syntax), so each file is highlighted once
2561/// rather than once per request — highlighting large files is by far the most
2562/// expensive thing a page view can do. The budget is
2563/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
2564/// RAM-constrained hosts). Concurrent misses may both compute and the last
2565/// insert wins; that's benign.
2566fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
2567 if budget_bytes == 0 {
2568 return Arc::new(highlight(path, text));
2569 }
2570 struct Cache {
2571 lru: lru::LruCache<String, Arc<Vec<String>>>,
2572 bytes: usize,
2573 }
2574 fn cost(key: &str, lines: &[String]) -> usize {
2575 key.len() + lines.iter().map(String::len).sum::<usize>()
2576 }
2577 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
2578 let cache = CACHE.get_or_init(|| {
2579 Mutex::new(Cache {
2580 lru: lru::LruCache::unbounded(),
2581 bytes: 0,
2582 })
2583 });
2584
2585 let ext = std::path::Path::new(path)
2586 .extension()
2587 .and_then(|e| e.to_str())
2588 .unwrap_or("");
2589 let key = format!("{oid}\x00{ext}");
2590 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
2591 return hit.clone();
2592 }
2593
2594 let lines = Arc::new(highlight(path, text));
2595 let mut c = cache.lock().expect("cache lock");
2596 c.bytes += cost(&key, &lines);
2597 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
2598 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
2599 }
2600 // Evict oldest entries until we're back under budget. An entry larger than
2601 // the whole budget evicts itself — memory stays bounded, it just never caches.
2602 while c.bytes > budget_bytes {
2603 let Some((k, v)) = c.lru.pop_lru() else { break };
2604 c.bytes -= cost(&k, &v);
2605 }
2606 lines
2607}
2608
2609/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
2610/// Falls back to escaped plain text for large files or on any failure.
2611fn highlight(path: &str, text: &str) -> Vec<String> {
2612 if text.len() > 512 * 1024 {
2613 return text.lines().map(escape).collect();
2614 }
2615 let (syntaxes, theme) = highlighter();
2616 let syntax = std::path::Path::new(path)
2617 .extension()
2618 .and_then(|e| e.to_str())
2619 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
2620 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
2621 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
2622
2623 let mut h = HighlightLines::new(syntax, theme);
2624 text.lines()
2625 .map(|line| match h.highlight_line(line, syntaxes) {
2626 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
2627 .unwrap_or_else(|_| escape(line)),
2628 Err(_) => escape(line),
2629 })
2630 .collect()
2631}
2632
2633fn escape(s: &str) -> String {
2634 s.replace('&', "&amp;")
2635 .replace('<', "&lt;")
2636 .replace('>', "&gt;")
2637}
2638
2639/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
2640pub(crate) fn fmt_time(secs: i64) -> String {
2641 match OffsetDateTime::from_unix_timestamp(secs) {
2642 Ok(t) => format!(
2643 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
2644 t.year(),
2645 u8::from(t.month()),
2646 t.day(),
2647 t.hour(),
2648 t.minute()
2649 ),
2650 Err(_) => secs.to_string(),
2651 }
2652}
2653
2654/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
2655pub(crate) fn fmt_relative(secs: i64) -> String {
2656 relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
2657}
2658
2659fn relative_to(secs: i64, now: i64) -> String {
2660 fn ago(n: i64, one: &str, unit: &str) -> String {
2661 if n == 1 {
2662 one.to_string()
2663 } else {
2664 format!("{n} {unit}s ago")
2665 }
2666 }
2667 let delta = now - secs;
2668 if delta < 60 {
2669 return "just now".to_string();
2670 }
2671 let minutes = delta / 60;
2672 if minutes < 60 {
2673 return ago(minutes, "1 minute ago", "minute");
2674 }
2675 let hours = delta / 3600;
2676 if hours < 24 {
2677 return ago(hours, "1 hour ago", "hour");
2678 }
2679 let days = delta / 86_400;
2680 if days < 7 {
2681 return ago(days, "yesterday", "day");
2682 }
2683 let weeks = days / 7;
2684 if weeks < 5 {
2685 return ago(weeks, "last week", "week");
2686 }
2687 let months = days / 30;
2688 if months < 12 {
2689 return ago(months, "last month", "month");
2690 }
2691 ago(days / 365, "last year", "year")
2692}
2693
2694/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
2695fn is_binary(bytes: &[u8]) -> bool {
2696 bytes.iter().take(8192).any(|&b| b == 0)
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701 use super::*;
2702
2703 #[test]
2704 fn markdown_by_extension_only() {
2705 assert!(is_markdown("README.md"));
2706 assert!(is_markdown("docs/guide.MarkDown"));
2707 assert!(!is_markdown("main.rs"));
2708 assert!(!is_markdown("md")); // no extension
2709 }
2710
2711 // Repo content is untrusted; rendered markdown must not become stored XSS.
2712 #[test]
2713 fn rendered_markdown_neutralizes_html_and_script_urls() {
2714 let out = render_markdown(
2715 "# title\n\n<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n![y](data:text/html,evil)\n\n[ok](https://example.com)\n",
2716 )
2717 .into_string();
2718 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
2719 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
2720 assert!(
2721 out.contains("&lt;script&gt;"),
2722 "raw HTML kept as text: {out}"
2723 );
2724 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
2725 assert!(!out.contains("data:"), "data URL dropped: {out}");
2726 assert!(
2727 out.contains(r#"href="https://example.com""#),
2728 "normal links survive: {out}"
2729 );
2730 }
2731
2732 #[test]
2733 fn relative_time_buckets() {
2734 const NOW: i64 = 1_000_000_000;
2735 let at = |delta: i64| relative_to(NOW - delta, NOW);
2736 assert_eq!(at(0), "just now");
2737 assert_eq!(at(59), "just now");
2738 assert_eq!(at(60), "1 minute ago");
2739 assert_eq!(at(45 * 60), "45 minutes ago");
2740 assert_eq!(at(3600), "1 hour ago");
2741 assert_eq!(at(23 * 3600), "23 hours ago");
2742 assert_eq!(at(86_400), "yesterday");
2743 assert_eq!(at(3 * 86_400), "3 days ago");
2744 assert_eq!(at(8 * 86_400), "last week");
2745 assert_eq!(at(20 * 86_400), "2 weeks ago");
2746 assert_eq!(at(40 * 86_400), "last month");
2747 assert_eq!(at(200 * 86_400), "6 months ago");
2748 assert_eq!(at(400 * 86_400), "last year");
2749 assert_eq!(at(900 * 86_400), "2 years ago");
2750 }
2751}