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::path::PathBuf;
6use std::sync::OnceLock;
7
8use anvil_core::{
9 App,
10 Repository,
11 SshKey,
12 User,
13 access,
14 repos,
15 ssh_keys,
16 users,
17};
18use anvil_git::browse::{
19 self,
20 ChangeKind,
21 FileChange,
22};
23use axum::{
24 Form,
25 Router,
26 extract::{
27 Path,
28 State,
29 },
30 http::{
31 StatusCode,
32 header,
33 },
34 response::{
35 IntoResponse,
36 Redirect,
37 Response,
38 },
39 routing::{
40 get,
41 post,
42 },
43};
44use maud::{
45 DOCTYPE,
46 Markup,
47 PreEscaped,
48 html,
49};
50use similar::{
51 ChangeTag,
52 TextDiff,
53};
54use syntect::easy::HighlightLines;
55use syntect::highlighting::{
56 Theme,
57 ThemeSet,
58};
59use syntect::html::{
60 IncludeBackground,
61 styled_line_to_highlighted_html,
62};
63use syntect::parsing::SyntaxSet;
64use time::OffsetDateTime;
65
66use crate::auth::CurrentUser;
67
68const STYLE: &str = r#"
69:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
70* { box-sizing:border-box; }
71body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
72a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
73header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
74.container { max-width:980px; margin:0 auto; padding:0 16px; }
75header.top .container { display:flex; align-items:center; gap:12px; }
76.brand { font-weight:700; font-size:16px; color:var(--fg); }
77main { padding:24px 0; }
78h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
79.muted { color:var(--muted); }
80.repo-list { list-style:none; padding:0; margin:0; }
81.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
82.repo-list .name { font-size:16px; font-weight:600; }
83.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
84.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
85.box .row:first-child { border-top:0; }
86.box .row a.entry { display:flex; gap:8px; align-items:center; }
87.icon { width:16px; color:var(--muted); }
88table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
89table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
90table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
91.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
92.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
93.clone-tabs { display:flex; gap:4px; margin-left:auto; }
94.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:2em; background:var(--bg); color:var(--muted); cursor:pointer; }
95.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
96.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
97.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
98.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
99.copy-btn:hover { color:var(--fg); }
100.copied-msg { display:none; color:#1a7f37; font-size:12px; }
101.clone.copied .copied-msg { display:inline; }
102.clone.copied .copy-btn { color:#1a7f37; }
103.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
104.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); }
105.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
106.linkbtn:hover { text-decoration:underline; }
107.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; }
108.btn:hover { text-decoration:none; opacity:.92; }
109form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
110form.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; }
111form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
112.commit-list { list-style:none; padding:0; margin:0; }
113.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
114.commit-list li:first-child { border-top:0; }
115.sha { font:12px ui-monospace,monospace; color:var(--muted); }
116.file-diff { margin:16px 0; }
117.file-diff .head { background:var(--code-bg); border:1px solid var(--border); border-bottom:0; border-radius:6px 6px 0 0; padding:6px 12px; font:12px ui-monospace,monospace; }
118table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
119table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
120table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
121table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
122.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
123.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
124footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
125"#;
126
127/// Clipboard icon for the clone "copy" button.
128const CLIPBOARD_SVG: &str = r#"<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></svg>"#;
129
130/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
131/// Registered once on `document`, so it survives htmx body swaps.
132const CLONE_JS: &str = r#"
133(function(){
134 function copyText(t){
135 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
136 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
137 document.body.appendChild(ta); ta.focus(); ta.select();
138 try{document.execCommand('copy')}catch(e){}
139 document.body.removeChild(ta); return Promise.resolve();
140 }
141 document.addEventListener('click', function(e){
142 var tab=e.target.closest('.clone-tab');
143 if(tab){
144 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
145 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
146 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
147 return;
148 }
149 var copy=e.target.closest('.copy-btn');
150 if(copy){
151 var box=copy.closest('.clone');
152 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
153 box.classList.add('copied');
154 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
155 });
156 }
157 });
158})();
159"#;
160
161/// Mount the web UI routes.
162pub fn routes(router: Router<App>) -> Router<App> {
163 router
164 .route("/", get(home))
165 .route("/-/settings", get(account_settings))
166 .route("/-/settings/keys", post(add_ssh_key))
167 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
168 .route("/-/new", get(new_repo_form).post(new_repo_submit))
169 .route("/{username}", get(user_profile))
170 .route(
171 "/{owner}/{repo}/settings",
172 get(repo_settings).post(repo_settings_submit),
173 )
174 .route("/{owner}/{repo}", get(repo_index))
175 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
176 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
177 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
178 .route("/{owner}/{repo}/commits/{rev}", get(commits))
179 .route("/{owner}/{repo}/commit/{id}", get(commit))
180 .route("/-/static/htmx.min.js", get(htmx_js))
181}
182
183/// Serve the vendored htmx script (embedded in the binary).
184async fn htmx_js() -> Response {
185 (
186 [(
187 header::CONTENT_TYPE,
188 "application/javascript; charset=utf-8",
189 )],
190 include_str!("../assets/htmx.min.js"),
191 )
192 .into_response()
193}
194
195pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
196 html! {
197 (DOCTYPE)
198 html lang="en" {
199 head {
200 meta charset="utf-8";
201 meta name="viewport" content="width=device-width, initial-scale=1";
202 title { (title) " · anvil" }
203 style { (PreEscaped(STYLE)) }
204 }
205 body hx-boost="true" {
206 header.top { div.container {
207 a.brand href="/" { "anvil" }
208 span style="margin-left:auto" {
209 @match user {
210 Some(u) => {
211 a href="/-/settings" { (u.username) }
212 " · "
213 form method="post" action="/-/logout" style="display:inline" {
214 button.linkbtn type="submit" { "sign out" }
215 }
216 }
217 None => { a href="/-/login" { "sign in" } }
218 }
219 }
220 } }
221 main { div.container { (body) } }
222 footer { div.container { "anvil — a minimal git forge" } }
223 script src="/-/static/htmx.min.js" {}
224 script { (PreEscaped(CLONE_JS)) }
225 }
226 }
227 }
228}
229
230fn not_found(message: &str) -> Response {
231 (
232 StatusCode::NOT_FOUND,
233 layout(
234 "Not found",
235 None,
236 html! { h1 { "Not found" } p.muted { (message) } },
237 ),
238 )
239 .into_response()
240}
241
242fn server_error(err: impl std::fmt::Display) -> Response {
243 tracing::error!("ui error: {err}");
244 (
245 StatusCode::INTERNAL_SERVER_ERROR,
246 layout("Error", None, html! { h1 { "Something went wrong" } }),
247 )
248 .into_response()
249}
250
251/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
252/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
253async fn resolve_repo(
254 app: &App,
255 viewer: Option<&User>,
256 owner: &str,
257 name: &str,
258) -> Result<(PathBuf, Repository), Response> {
259 let owner_user = users::find_by_username(&app.db, owner)
260 .await
261 .map_err(server_error)?
262 .ok_or_else(|| not_found("no such user"))?;
263 let repo = repos::find(&app.db, owner_user.id, name)
264 .await
265 .map_err(server_error)?
266 .ok_or_else(|| not_found("no such repository"))?;
267 if !access::can_read(&repo, viewer) {
268 return Err(not_found("no such repository"));
269 }
270 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
271 if !path.exists() {
272 return Err(not_found("repository not found on disk"));
273 }
274 Ok((path, repo))
275}
276
277/// `GET /` — list repositories visible to the current user.
278async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
279 let all = repos::list_all_with_owner(&app.db)
280 .await
281 .map_err(server_error)?;
282 let repos: Vec<_> = all
283 .into_iter()
284 .filter(|r| {
285 !r.is_private
286 || user
287 .as_ref()
288 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
289 })
290 .collect();
291 Ok(layout(
292 "Repositories",
293 user.as_ref(),
294 html! {
295 div style="display:flex;align-items:center" {
296 h1 style="margin-right:auto" { "Repositories" }
297 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
298 }
299 @if repos.is_empty() {
300 p.muted {
301 "No repositories yet. "
302 @if user.is_some() { a href="/-/new" { "Create one" } "." }
303 @else { "Sign in to create one." }
304 }
305 } @else {
306 ul.repo-list {
307 @for r in &repos {
308 li {
309 div.name {
310 a href=(format!("/{}", r.owner)) { (r.owner) }
311 "/"
312 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
313 @if r.is_private { " " span.pill { "private" } }
314 }
315 @if !r.description.is_empty() { div.muted { (r.description) } }
316 }
317 }
318 }
319 }
320 },
321 ))
322}
323
324/// `GET /{username}` — a user's profile: their repositories (public to all;
325/// private only to themselves or an admin).
326async fn user_profile(
327 State(app): State<App>,
328 CurrentUser(viewer): CurrentUser,
329 Path(username): Path<String>,
330) -> Result<Markup, Response> {
331 let owner = users::find_by_username(&app.db, &username)
332 .await
333 .map_err(server_error)?
334 .ok_or_else(|| not_found("no such user"))?;
335 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
336 .await
337 .map_err(server_error)?
338 .into_iter()
339 .filter(|r| access::can_read(r, viewer.as_ref()))
340 .collect();
341 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
342
343 Ok(layout(
344 &owner.username,
345 viewer.as_ref(),
346 html! {
347 div style="display:flex;align-items:center" {
348 h1 style="margin-right:auto" { (owner.username) }
349 @if is_self { a.btn href="/-/new" { "New repository" } }
350 }
351 @if !owner.email.is_empty() { p.muted { (owner.email) } }
352 h2 { "Repositories" }
353 @if visible.is_empty() {
354 p.muted { "No repositories." }
355 } @else {
356 ul.repo-list {
357 @for r in &visible {
358 li {
359 div.name {
360 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
361 @if r.is_private { " " span.pill { "private" } }
362 }
363 @if !r.description.is_empty() { div.muted { (r.description) } }
364 }
365 }
366 }
367 }
368 },
369 ))
370}
371
372#[derive(serde::Deserialize)]
373struct AddKeyForm {
374 #[serde(default)]
375 title: String,
376 key: String,
377}
378
379/// `GET /settings` — account settings: profile + SSH keys.
380async fn account_settings(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response {
381 let Some(user) = user else {
382 return Redirect::to("/-/login").into_response();
383 };
384 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
385 Ok(keys) => keys,
386 Err(e) => return server_error(e),
387 };
388 account_page(&user, &keys, None).into_response()
389}
390
391/// `POST /settings/keys` — register an SSH public key for the current user.
392async fn add_ssh_key(
393 State(app): State<App>,
394 CurrentUser(user): CurrentUser,
395 Form(form): Form<AddKeyForm>,
396) -> Response {
397 let Some(user) = user else {
398 return Redirect::to("/-/login").into_response();
399 };
400 let result = match ssh_keys::parse_public_key(&form.key) {
401 Ok((fingerprint, content)) => {
402 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
403 .await
404 .map(|_| ())
405 }
406 Err(e) => Err(e),
407 };
408 match result {
409 Ok(()) => Redirect::to("/-/settings").into_response(),
410 Err(e) => {
411 let keys = ssh_keys::list_by_user(&app.db, user.id)
412 .await
413 .unwrap_or_default();
414 (
415 StatusCode::BAD_REQUEST,
416 account_page(&user, &keys, Some(&e.to_string())),
417 )
418 .into_response()
419 }
420 }
421}
422
423/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
424async fn delete_ssh_key(
425 State(app): State<App>,
426 CurrentUser(user): CurrentUser,
427 Path(id): Path<i64>,
428) -> Response {
429 let Some(user) = user else {
430 return Redirect::to("/-/login").into_response();
431 };
432 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
433 return server_error(e);
434 }
435 Redirect::to("/-/settings").into_response()
436}
437
438fn account_page(user: &User, keys: &[SshKey], error: Option<&str>) -> Markup {
439 layout(
440 "Account settings",
441 Some(user),
442 html! {
443 h1 { "Account settings" }
444 p.muted {
445 "Signed in as " strong { (user.username) }
446 @if !user.email.is_empty() { " · " (user.email) }
447 }
448
449 h2 { "SSH keys" }
450 p.muted { "Add a public key to clone and push over SSH." }
451 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
452 @if keys.is_empty() {
453 p.muted { "No SSH keys yet." }
454 } @else {
455 div.box {
456 @for k in keys {
457 div.row {
458 div {
459 @if !k.title.is_empty() { strong { (k.title) } " " }
460 span.sha { (k.fingerprint) }
461 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
462 }
463 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
464 button.linkbtn type="submit" { "delete" }
465 }
466 }
467 }
468 }
469 }
470
471 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
472 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
473 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
474 p { button.btn type="submit" { "Add SSH key" } }
475 }
476 },
477 )
478}
479
480fn forbidden() -> Response {
481 (
482 StatusCode::FORBIDDEN,
483 layout(
484 "Forbidden",
485 None,
486 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
487 ),
488 )
489 .into_response()
490}
491
492#[derive(serde::Deserialize)]
493struct NewRepoForm {
494 name: String,
495 #[serde(default)]
496 description: String,
497 private: Option<String>,
498}
499
500#[derive(serde::Deserialize)]
501struct SettingsForm {
502 #[serde(default)]
503 description: String,
504 private: Option<String>,
505}
506
507/// `GET /new` — new-repository form (requires login).
508async fn new_repo_form(CurrentUser(user): CurrentUser) -> Response {
509 let Some(user) = user else {
510 return Redirect::to("/-/login").into_response();
511 };
512 new_repo_page(&user, None, "", "", false).into_response()
513}
514
515/// `POST /new` — create a repository owned by the current user.
516async fn new_repo_submit(
517 State(app): State<App>,
518 CurrentUser(user): CurrentUser,
519 Form(form): Form<NewRepoForm>,
520) -> Response {
521 let Some(user) = user else {
522 return Redirect::to("/-/login").into_response();
523 };
524 let private = form.private.is_some();
525 match repos::create(
526 &app.db,
527 &app.config.repositories_dir(),
528 &user,
529 &form.name,
530 &form.description,
531 private,
532 )
533 .await
534 {
535 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
536 Err(e) => (
537 StatusCode::BAD_REQUEST,
538 new_repo_page(
539 &user,
540 Some(&e.to_string()),
541 &form.name,
542 &form.description,
543 private,
544 ),
545 )
546 .into_response(),
547 }
548}
549
550fn new_repo_page(
551 user: &User,
552 error: Option<&str>,
553 name: &str,
554 description: &str,
555 private: bool,
556) -> Markup {
557 layout(
558 "New repository",
559 Some(user),
560 html! {
561 h1 { "New repository" }
562 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
563 form.stack method="post" action="/-/new" {
564 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
565 p { label { "Description" br; input type="text" name="description" value=(description); } }
566 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
567 p { button.btn type="submit" { "Create repository" } }
568 }
569 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
570 },
571 )
572}
573
574/// Load a repo for an owner-only settings action, enforcing write access.
575async fn resolve_for_settings(
576 app: &App,
577 viewer: Option<&User>,
578 owner: &str,
579 name: &str,
580) -> Result<Repository, Response> {
581 let owner_user = users::find_by_username(&app.db, owner)
582 .await
583 .map_err(server_error)?
584 .ok_or_else(|| not_found("no such repository"))?;
585 let repo = repos::find(&app.db, owner_user.id, name)
586 .await
587 .map_err(server_error)?
588 .ok_or_else(|| not_found("no such repository"))?;
589 if !access::can_read(&repo, viewer) {
590 return Err(not_found("no such repository"));
591 }
592 if !access::can_write(&repo, viewer) {
593 return Err(forbidden());
594 }
595 Ok(repo)
596}
597
598/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
599async fn repo_settings(
600 State(app): State<App>,
601 CurrentUser(user): CurrentUser,
602 Path((owner, repo)): Path<(String, String)>,
603) -> Response {
604 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
605 Ok(m) => m,
606 Err(resp) => return resp,
607 };
608 settings_page(user.as_ref(), &owner, &repo, &meta, None).into_response()
609}
610
611/// `POST /{owner}/{repo}/settings` — update description / visibility.
612async fn repo_settings_submit(
613 State(app): State<App>,
614 CurrentUser(user): CurrentUser,
615 Path((owner, repo)): Path<(String, String)>,
616 Form(form): Form<SettingsForm>,
617) -> Response {
618 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
619 Ok(m) => m,
620 Err(resp) => return resp,
621 };
622 if let Err(e) =
623 repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
624 {
625 return server_error(e);
626 }
627 Redirect::to(&format!("/{owner}/{repo}")).into_response()
628}
629
630fn settings_page(
631 user: Option<&User>,
632 owner: &str,
633 repo: &str,
634 meta: &Repository,
635 error: Option<&str>,
636) -> Markup {
637 layout(
638 &format!("{owner}/{repo}: settings"),
639 user,
640 html! {
641 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
642 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
643 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
644 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
645 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
646 p { button.btn type="submit" { "Save changes" } }
647 }
648 },
649 )
650}
651
652fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
653 let http = app.config.http_clone_url(owner, name);
654 let ssh = app
655 .config
656 .ssh
657 .enabled
658 .then(|| app.config.ssh_clone_url(owner, name));
659 html! {
660 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
661 div.clone-head {
662 span.muted { "Clone" }
663 div.clone-tabs {
664 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
665 @if ssh.is_some() {
666 button.clone-tab type="button" data-proto="ssh" { "SSH" }
667 }
668 }
669 }
670 div.clone-cmd {
671 code { "git clone " (http) }
672 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
673 (PreEscaped(CLIPBOARD_SVG))
674 }
675 span.copied-msg { "Copied!" }
676 }
677 }
678 }
679}
680
681/// `GET /{owner}/{repo}` — repository overview with the root tree.
682async fn repo_index(
683 State(app): State<App>,
684 CurrentUser(user): CurrentUser,
685 Path((owner, repo)): Path<(String, String)>,
686) -> Result<Markup, Response> {
687 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
688 let overview = browse::overview(&path).map_err(server_error)?;
689
690 let can_write = access::can_write(&meta, user.as_ref());
691 let header = html! {
692 div style="display:flex;align-items:center;gap:8px" {
693 h1 style="margin-right:auto" {
694 a href="/" { "anvil" } " / "
695 a href=(format!("/{owner}")) { (owner) } " / " (repo)
696 @if meta.is_private { " " span.pill { "private" } }
697 }
698 @if can_write {
699 a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
700 }
701 }
702 @if !meta.description.is_empty() { p.muted { (meta.description) } }
703 p {
704 span.pill { (overview.branches.len()) " branches" }
705 " "
706 span.pill { (overview.tags.len()) " tags" }
707 }
708 (clone_box(&app, &owner, &repo))
709 };
710
711 if overview.is_empty {
712 return Ok(layout(
713 &format!("{owner}/{repo}"),
714 user.as_ref(),
715 html! {
716 (header)
717 p.muted { "This repository is empty. Push to it to get started." }
718 },
719 ));
720 }
721
722 let rev = overview
723 .default_branch
724 .clone()
725 .unwrap_or_else(|| "HEAD".to_string());
726 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
727
728 Ok(layout(
729 &format!("{owner}/{repo}"),
730 user.as_ref(),
731 html! {
732 (header)
733 p.muted {
734 "Branch: " (rev) " · "
735 a href=(format!("/{owner}/{repo}/commits/{rev}")) { "commits" }
736 }
737 (tree_table(&owner, &repo, &rev, "", &entries))
738 },
739 ))
740}
741
742async fn tree_root(
743 State(app): State<App>,
744 user: CurrentUser,
745 Path((owner, repo, rev)): Path<(String, String, String)>,
746) -> Result<Markup, Response> {
747 render_tree(&app, user, &owner, &repo, &rev, "").await
748}
749
750async fn tree_path(
751 State(app): State<App>,
752 user: CurrentUser,
753 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
754) -> Result<Markup, Response> {
755 render_tree(&app, user, &owner, &repo, &rev, &path).await
756}
757
758async fn render_tree(
759 app: &App,
760 CurrentUser(user): CurrentUser,
761 owner: &str,
762 repo: &str,
763 rev: &str,
764 path: &str,
765) -> Result<Markup, Response> {
766 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
767 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
768 Ok(layout(
769 &format!("{owner}/{repo}: {path}"),
770 user.as_ref(),
771 html! {
772 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
773 (breadcrumbs(owner, repo, rev, path, false))
774 (tree_table(owner, repo, rev, path, &entries))
775 },
776 ))
777}
778
779/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file.
780async fn blob(
781 State(app): State<App>,
782 CurrentUser(user): CurrentUser,
783 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
784) -> Result<Markup, Response> {
785 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
786 let bytes = browse::read_blob(&repo_path, &rev, &path)
787 .map_err(server_error)?
788 .ok_or_else(|| not_found("file not found"))?;
789
790 let body = if is_binary(&bytes) {
791 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
792 } else {
793 let text = String::from_utf8_lossy(&bytes);
794 let lines = highlight(&path, &text);
795 html! {
796 table.code {
797 @for (i, line) in lines.iter().enumerate() {
798 tr {
799 td.ln { (i + 1) }
800 td { (PreEscaped(line)) }
801 }
802 }
803 }
804 }
805 };
806
807 Ok(layout(
808 &format!("{owner}/{repo}: {path}"),
809 user.as_ref(),
810 html! {
811 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
812 (breadcrumbs(&owner, &repo, &rev, &path, true))
813 div.box style="overflow-x:auto" { (body) }
814 },
815 ))
816}
817
818/// Render a tree listing as a box of rows; directories link to `tree`, files to `blob`.
819fn tree_table(
820 owner: &str,
821 repo: &str,
822 rev: &str,
823 path: &str,
824 entries: &[browse::TreeEntry],
825) -> Markup {
826 let join = |name: &str| {
827 if path.is_empty() {
828 name.to_string()
829 } else {
830 format!("{path}/{name}")
831 }
832 };
833 html! {
834 div.box {
835 @if !path.is_empty() {
836 div.row {
837 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
838 }
839 }
840 @for e in entries {
841 @let child = join(&e.name);
842 @let kind = if e.is_dir { "tree" } else { "blob" };
843 div.row {
844 a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) {
845 span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
846 (e.name) @if e.is_dir { "/" }
847 }
848 }
849 }
850 }
851 }
852}
853
854fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
855 match path.rsplit_once('/') {
856 Some((parent, _)) => format!("/{owner}/{repo}/tree/{rev}/{parent}"),
857 None => format!("/{owner}/{repo}/tree/{rev}"),
858 }
859}
860
861/// Path breadcrumbs. `is_blob` marks the final component as a file.
862fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
863 // Precompute (label, cumulative_path) for each path component.
864 let mut crumbs: Vec<(String, String)> = Vec::new();
865 let mut acc = String::new();
866 for part in path.split('/').filter(|p| !p.is_empty()) {
867 if !acc.is_empty() {
868 acc.push('/');
869 }
870 acc.push_str(part);
871 crumbs.push((part.to_string(), acc.clone()));
872 }
873 let last = crumbs.len();
874 html! {
875 div.crumbs {
876 a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) }
877 @for (i, (label, cum)) in crumbs.iter().enumerate() {
878 " / "
879 @if i + 1 == last && is_blob {
880 span { (label) }
881 } @else {
882 a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) }
883 }
884 }
885 }
886 }
887}
888
889/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
890async fn commits(
891 State(app): State<App>,
892 CurrentUser(user): CurrentUser,
893 Path((owner, repo, rev)): Path<(String, String, String)>,
894) -> Result<Markup, Response> {
895 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
896 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
897 Ok(layout(
898 &format!("{owner}/{repo}: commits"),
899 user.as_ref(),
900 html! {
901 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
902 ul.commit-list {
903 @for c in &log {
904 li {
905 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
906 span { (c.summary) }
907 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
908 }
909 }
910 }
911 },
912 ))
913}
914
915/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
916async fn commit(
917 State(app): State<App>,
918 CurrentUser(user): CurrentUser,
919 Path((owner, repo, id)): Path<(String, String, String)>,
920) -> Result<Markup, Response> {
921 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
922 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
923 Ok(layout(
924 &format!("{owner}/{repo}: {}", detail.info.short),
925 user.as_ref(),
926 html! {
927 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
928 p { (detail.info.summary) }
929 p.muted {
930 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
931 span.sha { (detail.info.id) }
932 @if let Some(parent) = &detail.parent {
933 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
934 }
935 }
936 @if detail.changes.is_empty() {
937 p.muted { "No file changes." }
938 }
939 @for change in &detail.changes {
940 (render_file_diff(change))
941 }
942 },
943 ))
944}
945
946/// Render one file's diff (added/deleted/modified) as a unified line diff.
947fn render_file_diff(change: &FileChange) -> Markup {
948 let (badge_cls, badge) = match change.kind {
949 ChangeKind::Added => ("add", "added"),
950 ChangeKind::Deleted => ("del", "deleted"),
951 ChangeKind::Modified => ("mod", "modified"),
952 };
953 let binary = change.old.as_deref().is_some_and(is_binary)
954 || change.new.as_deref().is_some_and(is_binary);
955 html! {
956 div.file-diff {
957 div.head {
958 span class=(format!("badge {badge_cls}")) { (badge) }
959 " " (change.path)
960 }
961 @if binary {
962 div.box { div.row { span.muted { "Binary file" } } }
963 } @else {
964 @let old = change.old.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
965 @let new = change.new.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
966 (unified_diff(&old, &new))
967 }
968 }
969 }
970}
971
972fn unified_diff(old: &str, new: &str) -> Markup {
973 let diff = TextDiff::from_lines(old, new);
974 html! {
975 table.code.diff {
976 @for change in diff.iter_all_changes() {
977 @let (sign, cls) = match change.tag() {
978 ChangeTag::Delete => ("-", "del"),
979 ChangeTag::Insert => ("+", "ins"),
980 ChangeTag::Equal => (" ", ""),
981 };
982 tr class=(cls) {
983 td.sign { (sign) }
984 td { (change.value().trim_end_matches('\n')) }
985 }
986 }
987 }
988 }
989}
990
991/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
992fn highlighter() -> &'static (SyntaxSet, Theme) {
993 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
994 HL.get_or_init(|| {
995 let syntaxes = SyntaxSet::load_defaults_newlines();
996 let themes = ThemeSet::load_defaults();
997 let theme = themes
998 .themes
999 .get("InspiredGitHub")
1000 .or_else(|| themes.themes.values().next())
1001 .cloned()
1002 .expect("at least one default theme");
1003 (syntaxes, theme)
1004 })
1005}
1006
1007/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1008/// Falls back to escaped plain text for large files or on any failure.
1009fn highlight(path: &str, text: &str) -> Vec<String> {
1010 if text.len() > 512 * 1024 {
1011 return text.lines().map(escape).collect();
1012 }
1013 let (syntaxes, theme) = highlighter();
1014 let syntax = std::path::Path::new(path)
1015 .extension()
1016 .and_then(|e| e.to_str())
1017 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1018 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1019 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1020
1021 let mut h = HighlightLines::new(syntax, theme);
1022 text.lines()
1023 .map(|line| match h.highlight_line(line, syntaxes) {
1024 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1025 .unwrap_or_else(|_| escape(line)),
1026 Err(_) => escape(line),
1027 })
1028 .collect()
1029}
1030
1031fn escape(s: &str) -> String {
1032 s.replace('&', "&amp;")
1033 .replace('<', "&lt;")
1034 .replace('>', "&gt;")
1035}
1036
1037/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1038fn fmt_time(secs: i64) -> String {
1039 match OffsetDateTime::from_unix_timestamp(secs) {
1040 Ok(t) => format!(
1041 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1042 t.year(),
1043 u8::from(t.month()),
1044 t.day(),
1045 t.hour(),
1046 t.minute()
1047 ),
1048 Err(_) => secs.to_string(),
1049 }
1050}
1051
1052/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1053fn is_binary(bytes: &[u8]) -> bool {
1054 bytes.iter().take(8192).any(|&b| b == 0)
1055}