78af4e78

Archive
Tree [78af4e78] [>]
commit
78af4e78ea236f102e16318fd37175c8478b14eb
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
changes
20
insertions
422
deletions
170
Add blame view, --bind/--base-url flags, and empty-repo handling
Msrc/cli.rs
~    /// Port to listen on.~    #[arg(long, default_value = "3000")]~    pub port: u16,++    /// Address to bind the HTTP server to.+    #[arg(long, default_value = "0.0.0.0")]+    pub bind: String,++    /// URL path prefix when served behind a reverse proxy (e.g. `/git`).+    /// All generated URLs will be prefixed with this value.+    #[arg(long)]+    pub base_url: Option<String>,~}
Msrc/main.rs
~/// SSH clone URL path prefix, set once from `--ssh-prefix`.~static SSH_PREFIX: OnceLock<String> = OnceLock::new();~+/// Base URL path prefix for reverse proxy support, set once from `--base-url`.+static BASE_URL: OnceLock<String> = OnceLock::new();+~/// Returns the configured hostname, or `"index"` if none was provided.~pub fn hostname() -> &'static str {~    HOSTNAME.get().map_or("index", |s| s.as_str())
~/// Returns the configured SSH prefix, or an empty string if none was provided.~pub fn ssh_prefix() -> &'static str {~    SSH_PREFIX.get().map_or("", |s| s.as_str())+}++/// Returns the configured base URL path prefix, or an empty string if none was+/// provided.+pub fn base_url() -> &'static str {+    BASE_URL.get().map_or("", |s| s.as_str())~}~~#[tokio::main]
~    }~    if let Some(ssh_prefix) = args.ssh_prefix {~        let _ = SSH_PREFIX.set(ssh_prefix);+    }+    if let Some(base_url) = args.base_url {+        let _ = BASE_URL.set(base_url);~    }~~    // Concatenate base styles, markdown styles, and theme variables into a
~    };~~    let router = routes::router(state);-    let addr = format!("0.0.0.0:{}", args.port);+    let addr = format!("{}:{}", args.bind, args.port);~    let socket = TcpListener::bind(&addr).await?;~~    axum::serve(socket, router).await?;
Msrc/routes.rs
~mod archive;+mod blame;~mod branch;~mod commit;~mod raw;
~        .route("/{repo}/commits/{sha}", get(commit::detail))~        .route("/{repo}/commits/{sha}/tree", get(tree::handler))~        .route("/{repo}/commits/{sha}/tree/{*path}", get(tree::handler))+        .route("/{repo}/commits/{sha}/blame/{*path}", get(blame::handler))~        .route("/{repo}/commits/{sha}/raw/{*path}", get(raw::handler))~        .route("/{repo}/archive/{format}/{*ref}", get(archive::handler))~        .with_state(state)
Mstatic/base.css
~}~~@layer components {-  /* Repo list */+  /* === Shared patterns === */++  :where(.repo-nav-item, .tree-row, details ul li a, details ul li button) {+    display: flex;+    align-items: center;+    gap: 1rem;+    padding: 0 1rem;+    min-height: 50px;+    font-family: var(--font-mono);+  }++  :where(.diff-stat-type, .diff-line-marker, .tree-icon, .summary-icon) {+    flex-shrink: 0;+    width: 1rem;+    text-align: center;+  }++  :where(.blame-linenum, .st-linenum) {+    min-width: 5ch;+    text-align: right;+    padding-right: 1.5ch;+    color: var(--secondary);+    user-select: none;+  }++  /* === Repo list === */+~  .repo-list { display: flex; flex-direction: column; }~~  .repo-card {
~  .repo-name { font-size: 1.1rem; font-weight: 600; color: var(--emphasized); }~  .repo-desc { color: var(--primary); }~  .repo-meta { display: flex; justify-content: space-between; color: var(--secondary); }++  /* === Nav items === */~-  /* Repo nav items */~  .repo-nav-item {-    display: flex;-    align-items: center;-    gap: 1rem;-    padding: 0 1rem;-    min-height: 50px;~    text-decoration: none;~    color: var(--primary);-    font-family: var(--font-mono);~    border-top: 1px solid var(--secondary);~  }~
~  .repo-nav-item-meta { margin-left: auto; color: var(--secondary); }~  .repo-nav-item-arrow { color: var(--secondary); }~-  /* Tag info */+  /* === Tag info === */+~  .tag-info {~    display: grid;~    grid-template-columns: max-content 1fr;
~    word-break: break-all;~    color: var(--primary);~  }++  /* === Branch / tag lists === */~-  /* Branch / tag lists */~  .branch-list, .tag-list { display: flex; flex-direction: column; }~~  .branch-row, .tag-row {
~    padding-left: 1rem;~  }~-  /* Commit log */+  /* === Commit log === */+~  .commit-log { display: flex; flex-direction: column; }~~  .commit-row {
~~  .commit-meta span:last-child { flex-shrink: 0; padding-left: 1rem; }~-  /* Commit graph layout */-  .commit-graph-layout {-    display: flex;-    align-items: flex-start;-  }+  /* === Commit graph === */~+  .commit-graph-layout { display: flex; align-items: flex-start; }+~  .commit-graph-svg {~    flex-shrink: 0;~    max-width: 10rem;
~    overflow-x: auto;~  }~-  .commit-graph-svg > svg {-    display: block;-  }+  .commit-graph-svg > svg { display: block; }~~  .commit-graph-rows {~    flex: 1;
~    overflow: hidden;~    text-overflow: ellipsis;~    white-space: nowrap;-  }--  .commit-date {-    flex-shrink: 0;-    color: var(--secondary);~  }~+  .commit-date { flex-shrink: 0; color: var(--secondary); }~  .commit-ref-branch { color: var(--blue); }~  .commit-ref-tag { color: var(--violet); }-  .tag-info .commit-ref-branch { color: var(--blue); }-  .tag-info .commit-ref-tag { color: var(--violet); }--  /* Diff stat */-  .diff-stat-type {-    flex-shrink: 0;-    width: 1rem;-    text-align: center;-  }++  /* === Diff stat === */++  .diff-stat-type { text-align: center; }~~  .diff-stat-path {~    flex: 1;
~  .diff-stat-M { color: var(--blue); }~  .diff-stat-R { color: var(--violet); }~-  /* Inline diff */-  .diff-files {-    margin: 0 0 1rem;-  }+  /* === Inline diff === */~+  .diff-files { margin: 0 0 1rem; }+~  .diff-line { display: flex; gap: 1rem; }~  .diff-line > span { white-space: pre; }--  .diff-line-marker {-    flex-shrink: 0;-    width: 1rem;-    text-align: center;-  }+  .diff-line-marker { text-align: center; }~~  .diff-hunks { padding: 0; margin: 0; }~
~  .diff-line-ctx { color: var(--secondary); }~  .diff-line-\+ { color: var(--green); }~  .diff-line-- { color: var(--red); }++  /* === Pagination === */++  .pagination {+    display: flex;+    justify-content: space-between;+    padding: .75rem 1rem;+    font-size: 1rem;+    font-family: var(--font-mono);+  }~-  /* Pagination */-  .pagination { display: flex; justify-content: space-between; padding: .75rem 1rem; font-size: 1rem; font-family: var(--font-mono); }~  .pagination a, .pagination span { flex: 1; text-align: center; }~  .pagination a { color: var(--secondary); text-decoration: none; }~  .pagination a:hover span:not(.pagination-sym) { text-decoration: underline; text-underline-offset: 3px; }~~  @media (max-width: 640px) {~    .commit-graph-row .commit-sha,-    .commit-graph-row .commit-date {-      display: none;-    }+    .commit-graph-row .commit-date { display: none; }~  }~-  /* Details / summary */+  /* === Details / summary === */+~  details { border-top: 1px solid var(--highlight); }~  details:first-of-type { border-top: 1px solid var(--secondary); }~  details:last-of-type { border-bottom: 1px solid var(--highlight); }
~    background: var(--highlight);~  }~-  .summary-icon { width: 1rem; height: 1rem; flex-shrink: 0; }+  .summary-icon { height: 1rem; }~~  details[open] summary { color: var(--emphasized); }~  summary::after { content: '[+]'; color: var(--secondary); }
~    font-family: var(--font-mono);~  }~-  details ul li:has(a),-  details ul li:has(button) { padding: 0; }+  details ul li:has(a), details ul li:has(button) { padding: 0; }~~  details ul li a,~  details ul li button {-    display: flex;-    align-items: center;-    gap: 1rem;-    padding: 0 1rem;-    min-height: 50px;~    width: 100%;~    text-align: left;~    text-decoration: none;
~    background: transparent;~    border: none;~    cursor: pointer;-    font-family: var(--font-mono);~    font-size: 1rem;~  }~
~~  details ul li a.see-all span:first-child { color: var(--secondary); }~-  /* First span grows; truncates on overflow */~  details ul li a span:first-child,~  details ul li button span:first-child {~    flex: 1;
~    color: var(--primary);~  }~-  /* Clone rows: URL animates on copy */~  details ul li button span:first-child { transition: opacity .1s; }+~  details ul li button span:nth-child(2) {~    flex-shrink: 0;~    min-width: 4rem;
~    color: var(--secondary);~  }~-  /* Branch/tag rows: remaining spans fixed right */~  details ul li a span:not(:first-child) { flex-shrink: 0; color: var(--secondary); }~-  /* Tree listing */-  .tree-list { display: flex; flex-direction: column; border-top: 1px solid var(--secondary); border-bottom: 1px solid var(--highlight); }+  /* === Tree listing === */~-  .tree-row {+  .tree-list {~    display: flex;-    align-items: center;-    gap: 1rem;-    padding: 0 1rem;-    min-height: 50px;-    text-decoration: none;-    color: inherit;-    font-family: var(--font-mono);+    flex-direction: column;+    border-top: 1px solid var(--secondary);+    border-bottom: 1px solid var(--highlight);~  }~-  .tree-icon {-    flex-shrink: 0;-    width: 1rem;-    text-align: center;-    color: var(--secondary);-  }+  .tree-row { text-decoration: none; color: inherit; }+  .tree-icon { color: var(--secondary); }~~  .tree-name {~    flex: 1;
~  .tree-row:hover .tree-name { text-decoration: underline; text-underline-offset: 3px; }~  .tree-row:active { background-color: var(--highlight); }~  .tree-row + .tree-row { border-top: 1px solid var(--highlight); }-~  .tree-size { flex-shrink: 0; color: var(--secondary); }~-  /* File view */-  .file-view > .tree-row {-    border-top: 1px solid var(--secondary);-  }+  /* === File view === */~-  .file-view > .tree-row:first-child .tree-icon {-    color: var(--primary);-  }+  .file-view > .tree-row { border-top: 1px solid var(--secondary); }+  .file-view > .tree-row:first-child .tree-icon { color: var(--primary); }+  .file-view > .tree-row + .repo-nav > .repo-nav-item { border-top-color: var(--highlight); }~-  .file-view > .repo-nav > .repo-nav-item {-    border-top-color: var(--highlight);+  .file-view > .repo-nav > .repo-nav-item:last-child {+    border-bottom: 1px solid var(--secondary);~  }~-  .file-view .repo-nav-item-arrow {-    margin-left: auto;-  }+  .file-view .repo-nav-item-arrow { margin-left: auto; }~-  .file-content {-    margin: 0;-  }+  .file-content { margin: 0; }~~  .file-content pre {~    font-family: var(--font-mono);
~    max-width: none;~    white-space: pre;~    background: transparent;+  }++  /* === Blame view === */++  .blame-table {+    font-family: var(--font-mono);+    line-height: 1.45;+    width: 100%;+    border-collapse: collapse;+  }++  .blame-wrap {+    overflow-x: auto;+    padding: 1rem 1rem 1rem 0;+  }++  .blame-row:nth-child(even) { background: var(--highlight); }++  .blame-sha {+    width: 8ch;+    padding: 0 .5rem 0 1rem;+    text-align: right;+    white-space: nowrap;+  }++  .blame-sha a { text-decoration: none; }+  .blame-sha a:hover { text-decoration: underline; text-underline-offset: 3px; }++  .blame-linenum {+    width: 5ch;+    padding: 0 1.5ch 0 .5ch;+    text-align: right;+    color: var(--secondary);+    user-select: none;+  }++  .blame-code {+    padding: 0 1rem 0 0;+    white-space: pre;~  }~-  /* Syntax highlighting */+  /* === Syntax highlighting === */+~  .st-comment { color: var(--secondary); }~  .st-string { color: var(--green); }~  .st-keyword { color: var(--blue); }
~  .st-meta { color: var(--secondary); }~  .st-section { color: var(--blue); }~  .st-underline { text-decoration: underline; }-  .st-linenum {-    display: inline-block;-    min-width: 5ch;-    text-align: right;-    padding: 0 1.5ch 0 0.5ch;-    color: var(--secondary);-    user-select: none;-  }+  .st-linenum { display: inline-block; }~}~~@layer accents {
Mtemplates/base.html
~    <meta charset="UTF-8">~    <meta name="viewport" content="width=device-width, initial-scale=1.0">~    <title>{% block title %}{% endblock %}</title>-    <link rel="stylesheet" href="/style.css">+    <link rel="stylesheet" href="{{ crate::base_url() }}/style.css">~</head>~<body>~{% include "sprites.svg" %}
Mtemplates/branch_detail.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>-  <li><a href="/{{ repo }}/branches">branches</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}/branches">branches</a></li>~  <li>{{ branch }}</li>~</ul>~{% endblock %}
~<details>~  <summary><svg class="summary-icon"><use href="#icon-archive"/></svg>Archive</summary>~  <ul>-    <li><a href="/{{ repo }}/archive/tar.gz/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.tar.gz"><span class="archive-link">{{ repo }}-{{ branch }}.tar.gz</span><span>tar.gz</span></a></li>-    <li><a href="/{{ repo }}/archive/tar.xz/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.tar.xz"><span class="archive-link">{{ repo }}-{{ branch }}.tar.xz</span><span>tar.xz</span></a></li>-    <li><a href="/{{ repo }}/archive/zip/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.zip"><span class="archive-link">{{ repo }}-{{ branch }}.zip</span><span>zip</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.gz/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.tar.gz"><span class="archive-link">{{ repo }}-{{ branch }}.tar.gz</span><span>tar.gz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.xz/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.tar.xz"><span class="archive-link">{{ repo }}-{{ branch }}.tar.xz</span><span>tar.xz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/zip/refs/heads/{{ branch }}" download="{{ repo }}-{{ branch }}.zip"><span class="archive-link">{{ repo }}-{{ branch }}.zip</span><span>zip</span></a></li>~  </ul>~</details>~~<div class="repo-nav">-  <a href="/{{ repo }}/commits/{{ tip_sha }}/tree" class="repo-nav-item">+  <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ tip_sha }}/tree" class="repo-nav-item">~    <svg class="summary-icon"><use href="#icon-tree"/></svg>~    <span>Tree</span>~    <span class="repo-nav-item-meta">[{{ tip_sha[..8] }}]</span>
~{% else %}~<div class="commit-log">~  {% for commit in commits %}-  <a href="/{{ repo }}/commits/{{ commit.sha }}" class="commit-row">+  <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ commit.sha }}" class="commit-row">~    <span class="commit-sha">{{ commit.sha[..8] }}</span>~    <span class="commit-message">{{ commit.message }}</span>~    <span class="commit-meta"><span>{{ commit.author }}</span><span>{{ commit.date }}</span></span>
Mtemplates/branch_list.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>~  <li>branches</li>~</ul>~{% endblock %}
~{% else %}~<div class="branch-list">~  {% for branch in branches %}-  <a href="/{{ repo }}/branches/{{ branch.name }}" class="branch-row">+  <a href="{{ crate::base_url() }}/{{ repo }}/branches/{{ branch.name }}" class="branch-row">~    <span class="branch-name">{{ branch.name }}</span>~    <span class="branch-date">{% if branch.is_default %}<span class="branch-default">default</span>{% endif %}{{ branch.date }}</span>~  </a>
Mtemplates/commit.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>-  <li><a href="/{{ repo }}/commits">commits</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}/commits">commits</a></li>~  <li>{{ sha[..8] }}</li>~</ul>~{% endblock %}
~<details>~  <summary><svg class="summary-icon"><use href="#icon-archive"/></svg>Archive</summary>~  <ul>-    <li><a href="/{{ repo }}/archive/tar.gz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.gz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.gz</span><span>tar.gz</span></a></li>-    <li><a href="/{{ repo }}/archive/tar.xz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.xz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.xz</span><span>tar.xz</span></a></li>-    <li><a href="/{{ repo }}/archive/zip/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.zip"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.zip</span><span>zip</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.gz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.gz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.gz</span><span>tar.gz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.xz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.xz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.xz</span><span>tar.xz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/zip/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.zip"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.zip</span><span>zip</span></a></li>~  </ul>~</details>~~<div class="repo-nav">-  <a href="/{{ repo }}/commits/{{ sha }}/tree" class="repo-nav-item">+  <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ sha }}/tree" class="repo-nav-item">~    <svg class="summary-icon"><use href="#icon-tree"/></svg>~    <span>Tree</span>~    <span class="repo-nav-item-meta">[{{ sha[..8] }}]</span>
~{% if !refs.is_empty() %}~<dl class="tag-info">~  <dt>refs</dt>-  <dd>{% for r in refs %}<div class="commit-ref-row"><a href="/{{ repo }}/{% if r.kind == "branch" %}branches{% else %}tags{% endif %}/{{ r.name }}" class="commit-ref commit-ref-{{ r.kind }}">{{ r.name }}</a></div>{% endfor %}</dd>+  <dd>{% for r in refs %}<div class="commit-ref-row"><a href="{{ crate::base_url() }}/{{ repo }}/{% if r.kind == "branch" %}branches{% else %}tags{% endif %}/{{ r.name }}" class="commit-ref commit-ref-{{ r.kind }}">{{ r.name }}</a></div>{% endfor %}</dd>~</dl>~{% endif %}~~<dl class="tag-info">-  <dt>commit</dt><dd><a class="commit-sha" href="/{{ repo }}/commits/{{ sha }}">{{ sha }}</a></dd>+  <dt>commit</dt><dd><a class="commit-sha" href="{{ crate::base_url() }}/{{ repo }}/commits/{{ sha }}">{{ sha }}</a></dd>~  <dt>parent{% if parents.len() != 1 %}s{% endif %}</dt>-  <dd>{% for (parent_sha, _) in parents %}<div class="commit-parent"><a class="commit-sha" href="/{{ repo }}/commits/{{ parent_sha }}">{{ parent_sha }}</a></div>{% endfor %}</dd>+  <dd>{% for (parent_sha, _) in parents %}<div class="commit-parent"><a class="commit-sha" href="{{ crate::base_url() }}/{{ repo }}/commits/{{ parent_sha }}">{{ parent_sha }}</a></div>{% endfor %}</dd>~  <dt>author</dt><dd>{{ author }}</dd>~  <dt>date</dt><dd>{{ date }}</dd>~</dl>
Mtemplates/commit_list.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>~  <li>commits</li>~</ul>~{% endblock %}
~  <div class="commit-graph-svg">{{ graph_svg|safe }}</div>~  <div class="commit-graph-rows">~    {% for commit in commits %}-    <a href="/{{ repo }}/commits/{{ commit.sha }}" class="commit-graph-row" style="border-left: 3px solid {{ commit.color }}">+    <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ commit.sha }}" class="commit-graph-row" style="border-left: 3px solid {{ commit.color }}">~      <span class="commit-message">{% for r in commit.refs %}<span class="commit-ref commit-ref-{{ r.kind }}">{{ r.name }}</span> {% endfor %}{{ commit.message }}</span>~      <span class="commit-sha">{{ commit.sha[..8] }}</span>~      <span class="commit-date">{{ commit.date }}</span>
Mtemplates/repo.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ name }}">{{ name }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ name }}">{{ name }}</a></li>~</ul>~{% endblock %}~
~  <summary><svg class="summary-icon"><use href="#icon-copy"/></svg>Clone</summary>~  <ul>~    <li><button data-url="git@{{ crate::hostname() }}:{{ crate::ssh_prefix() }}{{ name }}.git"><span>git@{{ crate::hostname() }}:{{ crate::ssh_prefix() }}{{ name }}.git</span><span>SSH</span></button></li>-    <li><button data-url="https://{{ crate::hostname() }}/{{ name }}.git"><span>https://{{ crate::hostname() }}/{{ name }}.git</span><span>HTTPS</span></button></li>+    <li><button data-url="https://{{ crate::hostname() }}{{ crate::base_url() }}/{{ name }}.git"><span>https://{{ crate::hostname() }}/{{ name }}.git</span><span>HTTPS</span></button></li>~  </ul>~</details>~
~<details>~  <summary><svg class="summary-icon"><use href="#icon-archive"/></svg>Archive</summary>~  <ul>-    <li><a href="/{{ name }}/archive/tar.gz/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.tar.gz"><span class="archive-link">{{ name }}-{{ branch }}.tar.gz</span><span>tar.gz</span></a></li>-    <li><a href="/{{ name }}/archive/tar.xz/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.tar.xz"><span class="archive-link">{{ name }}-{{ branch }}.tar.xz</span><span>tar.xz</span></a></li>-    <li><a href="/{{ name }}/archive/zip/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.zip"><span class="archive-link">{{ name }}-{{ branch }}.zip</span><span>zip</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/archive/tar.gz/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.tar.gz"><span class="archive-link">{{ name }}-{{ branch }}.tar.gz</span><span>tar.gz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/archive/tar.xz/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.tar.xz"><span class="archive-link">{{ name }}-{{ branch }}.tar.xz</span><span>tar.xz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/archive/zip/refs/heads/{{ branch }}" download="{{ name }}-{{ branch }}.zip"><span class="archive-link">{{ name }}-{{ branch }}.zip</span><span>zip</span></a></li>~  </ul>~</details>~{% endif %}
~  <summary><svg class="summary-icon"><use href="#icon-branch"/></svg>Branches <span>[{{ branches.len() }}]</span></summary>~  <ul>~    {% for branch in branches.iter().take(5) %}-    <li><a href="/{{ name }}/branches/{{ branch.name }}"><span class="branch-name">{{ branch.name }}</span>{% if branch.is_default %}<span>default</span>{% endif %}<span>{{ branch.date }}</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/branches/{{ branch.name }}"><span class="branch-name">{{ branch.name }}</span>{% if branch.is_default %}<span>default</span>{% endif %}<span>{{ branch.date }}</span></a></li>~    {% endfor %}~    {% if branches.len() > 5 %}-    <li><a href="/{{ name }}/branches" class="see-all"><span>see all {{ branches.len() }} branches</span><span>[&gt;]</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/branches" class="see-all"><span>see all {{ branches.len() }} branches</span><span>[&gt;]</span></a></li>~    {% endif %}~    {% if branches.is_empty() %}~    <li>no branches</li>
~  <summary><svg class="summary-icon"><use href="#icon-tag"/></svg>Tags <span>[{{ tags.len() }}]</span></summary>~  <ul>~    {% for tag in tags.iter().take(5) %}-    <li><a href="/{{ name }}/tags/{{ tag.name }}"><span class="tag-name">{{ tag.name }}</span><span>{{ tag.date }}</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/tags/{{ tag.name }}"><span class="tag-name">{{ tag.name }}</span><span>{{ tag.date }}</span></a></li>~    {% endfor %}~    {% if tags.len() > 5 %}-    <li><a href="/{{ name }}/tags" class="see-all"><span>see all {{ tags.len() }} tags</span><span>[&gt;]</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ name }}/tags" class="see-all"><span>see all {{ tags.len() }} tags</span><span>[&gt;]</span></a></li>~    {% endif %}~    {% if tags.is_empty() %}~    <li>no tags</li>
~~<div class="repo-nav">~  {% if let Some(sha) = head_sha %}-  <a href="/{{ name }}/commits/{{ sha }}/tree" class="repo-nav-item">+  <a href="{{ crate::base_url() }}/{{ name }}/commits/{{ sha }}/tree" class="repo-nav-item">~    <svg class="summary-icon"><use href="#icon-tree"/></svg>~    <span>Tree</span>~    <span class="repo-nav-item-meta">[{{ sha[..8] }}]</span>~    <span class="repo-nav-item-arrow">[&gt;]</span>~  </a>~  {% endif %}-  <a href="/{{ name }}/commits" class="repo-nav-item">+  <a href="{{ crate::base_url() }}/{{ name }}/commits" class="repo-nav-item">~    <svg class="summary-icon"><use href="#icon-commits"/></svg>~    <span>Commits</span>~    <span class="repo-nav-item-meta">[{{ commit_count }}]</span>
Mtemplates/repo_list.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>~</ul>~{% endblock %}~
~{% else %}~<div class="repo-list">~  {% for repo in repos %}-  <a href="/{{ repo.name }}" class="repo-card">+  <a href="{{ crate::base_url() }}/{{ repo.name }}" class="repo-card">~    <span class="repo-name">{{ repo.name }}</span>~    {% if let Some(desc) = &repo.description %}~    <span class="repo-desc">{{ desc }}</span>
Mtemplates/sprites.svg
~    <polyline points="7 10 12 15 17 10"/>~    <line x1="12" y1="15" x2="12" y2="3"/>~  </symbol>+  <symbol id="icon-blame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+    <circle cx="12" cy="12" r="10"/>+    <polyline points="12 6 12 12 16 14"/>+  </symbol>~</svg>
Mtemplates/tag_detail.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>-  <li><a href="/{{ repo }}/tags">tags</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}/tags">tags</a></li>~  <li>{{ tag }}</li>~</ul>~{% endblock %}
~<details>~  <summary><svg class="summary-icon"><use href="#icon-archive"/></svg>Archive</summary>~  <ul>-    <li><a href="/{{ repo }}/archive/tar.gz/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.tar.gz"><span class="archive-link">{{ repo }}-{{ tag }}.tar.gz</span><span>tar.gz</span></a></li>-    <li><a href="/{{ repo }}/archive/tar.xz/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.tar.xz"><span class="archive-link">{{ repo }}-{{ tag }}.tar.xz</span><span>tar.xz</span></a></li>-    <li><a href="/{{ repo }}/archive/zip/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.zip"><span class="archive-link">{{ repo }}-{{ tag }}.zip</span><span>zip</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.gz/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.tar.gz"><span class="archive-link">{{ repo }}-{{ tag }}.tar.gz</span><span>tar.gz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/tar.xz/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.tar.xz"><span class="archive-link">{{ repo }}-{{ tag }}.tar.xz</span><span>tar.xz</span></a></li>+    <li><a href="{{ crate::base_url() }}/{{ repo }}/archive/zip/refs/tags/{{ tag }}" download="{{ repo }}-{{ tag }}.zip"><span class="archive-link">{{ repo }}-{{ tag }}.zip</span><span>zip</span></a></li>~  </ul>~</details>~~<div class="repo-nav">-  <a href="/{{ repo }}/commits/{{ commit_sha }}/tree" class="repo-nav-item">+  <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ commit_sha }}/tree" class="repo-nav-item">~    <svg class="summary-icon"><use href="#icon-tree"/></svg>~    <span>Tree</span>~    <span class="repo-nav-item-meta">[{{ commit_sha[..8] }}]</span>
~{% endif %}~~<dl class="tag-info">-  <dt>commit</dt><dd><a class="commit-sha" href="/{{ repo }}/commits/{{ commit_sha }}">{{ commit_sha }}</a></dd>+  <dt>commit</dt><dd><a class="commit-sha" href="{{ crate::base_url() }}/{{ repo }}/commits/{{ commit_sha }}">{{ commit_sha }}</a></dd>~  <dt>author</dt><dd>{{ commit_author }}</dd>~  <dt>date</dt><dd>{{ commit_date }}</dd>~</dl>
Mtemplates/tag_list.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>~  <li>tags</li>~</ul>~{% endblock %}
~{% else %}~<div class="tag-list">~  {% for tag in tags %}-  <a href="/{{ repo }}/tags/{{ tag.name }}" class="tag-row">+  <a href="{{ crate::base_url() }}/{{ repo }}/tags/{{ tag.name }}" class="tag-row">~    <span class="tag-name">{{ tag.name }}</span>~    <span class="tag-date">{{ tag.date }}</span>~  </a>
Mtemplates/tree.html
~~{% block breadcrumbs %}~<ul>-  <li><a href="/">{{ crate::hostname() }}</a></li>-  <li><a href="/{{ repo }}">{{ repo }}</a></li>-  <li><a href="/{{ repo }}/commits/{{ sha }}">{{ sha[..8] }}</a></li>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ sha }}">{{ sha[..8] }}</a></li>~  {% for crumb in breadcrumbs %}~  <li>{% if loop.last %}{{ crumb.label }}{% else %}<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% endif %}</li>~  {% endfor %}
~    <a href="{{ raw_url }}" class="repo-nav-item">~      <svg class="summary-icon"><use href="#icon-raw"/></svg>~      <span>Raw</span>+      <span class="repo-nav-item-arrow">[&gt;]</span>+    </a>+    <a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ sha }}/blame/{{ path }}" class="repo-nav-item">+      <svg class="summary-icon"><use href="#icon-blame"/></svg>+      <span>Blame</span>~      <span class="repo-nav-item-arrow">[&gt;]</span>~    </a>~  </div>
Msrc/routes/commit.rs
~}~~/// Loads up to `limit` commits from `HEAD`, walking ancestors.+/// Returns an empty vec if the repository has no commits.~fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {-    let head = git_repo-        .head_commit()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    let head = match git_repo.head_commit() {+        Ok(head) => head,+        Err(_) => return Ok(Vec::new()),+    };~~    let entries = head~        .id()
Msrc/routes/repo.rs
~            // Rewrite relative image URLs to the raw file serving route so~            // that images committed alongside the README render correctly.~            let mut rendered = String::new();-            let events = Parser::new_ext(text, Options::all()).map(|event| match event {+            let events = Parser::new_ext(text, Options::empty()).map(|event| match event {~                Event::Start(Tag::Image {~                    link_type,~                    dest_url,
~                }) => {~                    let dest_url = match &head_sha {~                        Some(sha) if is_relative_url(&dest_url) => {-                            format!("/{}/commits/{}/raw/{}", name, sha, dest_url).into()+                            format!("{}/{}/commits/{}/raw/{}", crate::base_url(), name, sha, dest_url).into()~                        }~                        _ => dest_url,~                    };
Msrc/routes/tree.rs
~        (obj, path.clone())~    };~-    let breadcrumbs = build_breadcrumbs(&name, &params.sha, &resolved_path);+    let b = crate::base_url();+    let breadcrumbs = build_breadcrumbs(&b, &name, &params.sha, &resolved_path);~    let raw_url = if resolved_path.is_empty() {~        String::new()~    } else {-        format!("/{name}/commits/{}/raw/{}", params.sha, resolved_path)+        format!("{b}/{name}/commits/{}/raw/{}", params.sha, resolved_path)~    };~    let parent_url = if resolved_path.is_empty() {~        String::new()~    } else {~        let parent_path = resolved_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");~        if parent_path.is_empty() {-            format!("/{name}/commits/{}/tree", params.sha)+            format!("{b}/{name}/commits/{}/tree", params.sha)~        } else {-            format!("/{name}/commits/{}/tree/{parent_path}", params.sha)+            format!("{b}/{name}/commits/{}/tree/{parent_path}", params.sha)~        }~    };~
~                            .unwrap_or_default()~                    };~                    let url = if resolved_path.is_empty() {-                        format!("/{name}/commits/{}/tree/{entry_name}", params.sha)+                        format!("{b}/{name}/commits/{}/tree/{entry_name}", params.sha)~                    } else {~                        format!(-                            "/{name}/commits/{}/tree/{resolved_path}/{entry_name}",+                            "{b}/{name}/commits/{}/tree/{resolved_path}/{entry_name}",~                            params.sha~                        )~                    };
~///~/// The root breadcrumb is always "tree".  Each path segment adds a breadcrumb~/// linking to its corresponding subdirectory.-fn build_breadcrumbs(repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {+fn build_breadcrumbs(base: &str, repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {~    let mut crumbs = Vec::new();~    crumbs.push(Breadcrumb {~        label: "tree".to_string(),-        url: format!("/{repo}/commits/{sha}/tree"),+        url: format!("{base}/{repo}/commits/{sha}/tree"),~    });~    if !path.is_empty() {~        let mut accumulated = String::new();
~            accumulated.push_str(segment);~            crumbs.push(Breadcrumb {~                label: segment.to_string(),-                url: format!("/{repo}/commits/{sha}/tree/{accumulated}"),+                url: format!("{base}/{repo}/commits/{sha}/tree/{accumulated}"),~            });~        }~    }
Asrc/routes/blame.rs
+//! Blame/annotate view for files.+//!+//! Covers one route: `/{repo}/commits/{sha}/blame/{*path}`.  The handler+//! resolves the file at `{sha}:{path}` and runs `gix`'s blame algorithm to+//! attribute each line to the commit that last modified it.  Results are+//! displayed in a three-column table: commit SHA, line number, and code+//! content (cgit style).++use askama::Template;+use askama_web::WebTemplate;+use axum::extract::{Path, State};+use axum::response::IntoResponse;+use serde::Deserialize;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName};++/// Path parameters for the blame route.+#[derive(Deserialize)]+pub(super) struct BlamePath {+    sha: String,+    path: String,+}++/// A single annotated line in the blame view.+struct BlameLine {+    /// Line number (1-based).+    num: usize,+    /// Full commit SHA.+    sha: String,+    /// Short SHA (first 8 characters).+    short_sha: String,+    /// Color variable for the SHA column.+    color: String,+    /// The line content.+    content: String,+}++/// A breadcrumb segment for the navigation bar.+struct Breadcrumb {+    label: String,+    url: String,+}++/// Template data for the blame page.+#[derive(Template, WebTemplate)]+#[template(path = "blame.html")]+struct BlameView {+    repo: String,+    sha: String,+    path: String,+    tree_url: String,+    breadcrumbs: Vec<Breadcrumb>,+    lines: Vec<BlameLine>,+}++/// Color palette for commit highlighting (matches commit.rs graph colors).+const COLORS: &[&str] = &[+    "var(--blue)",+    "var(--red)",+    "var(--green)",+    "var(--yellow)",+    "var(--violet)",+    "var(--magenta)",+    "var(--cyan)",+    "var(--orange)",+];++fn commit_color(sha: &str) -> &'static str {+    let hash: u64 = sha.bytes().fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));+    COLORS[hash as usize % COLORS.len()]+}++/// Renders the blame view for a file.+pub async fn handler(+    State(state): State<AppState>,+    RepoName(name): RepoName,+    Path(params): Path<BlamePath>,+) -> Result<impl IntoResponse> {+    if std::path::Path::new(&params.path)+        .components()+        .any(|c| !matches!(c, std::path::Component::Normal(_)))+    {+        return Err(Error::BadRequest("invalid path".to_string()));+    }++    let git_repo =+        gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;++    let oid = gix::ObjectId::from_hex(params.sha.as_bytes())+        .map_err(|_| Error::BadRequest(format!("invalid sha: {}", params.sha)))?;++    // Verify the commit exists.+    let _commit = git_repo+        .find_object(oid)+        .map_err(|_| Error::NotFound(params.sha.clone()))?+        .try_into_commit()+        .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?;++    let file_path = gix::bstr::BStr::new(params.path.as_bytes());++    let opts = gix::repository::blame_file::Options::default();+    let outcome = git_repo+        .blame_file(file_path, oid, opts)+        .map_err(|e| Error::GitCorrupt(e.to_string()))?;++    // Split the blob into lines.+    let content = String::from_utf8_lossy(&outcome.blob).into_owned();+    let file_lines: Vec<&str> = content.split('\n').collect();++    // Build per-line blame entries.+    let mut lines: Vec<BlameLine> = Vec::new();+    for entry in &outcome.entries {+        let sha = entry.commit_id.to_string();+        let color = commit_color(&sha);+        let short_sha = sha[..8].to_string();++        for i in 0..entry.len.get() {+            let line_idx = (entry.start_in_blamed_file + i) as usize;+            let content = file_lines.get(line_idx).unwrap_or(&"").to_string();+            lines.push(BlameLine {+                num: line_idx + 1,+                sha: sha.clone(),+                short_sha: short_sha.clone(),+                color: color.to_string(),+                content,+            });+        }+    }++    let b = crate::base_url();+    let tree_url = format!("{b}/{name}/commits/{}/tree/{}", params.sha, params.path);++    let mut breadcrumbs = Vec::new();+    breadcrumbs.push(Breadcrumb {+        label: "tree".to_string(),+        url: format!("{b}/{name}/commits/{}/tree", params.sha),+    });+    if !params.path.is_empty() {+        let mut accumulated = String::new();+        for segment in params.path.split('/') {+            if !accumulated.is_empty() {+                accumulated.push('/');+            }+            accumulated.push_str(segment);+            breadcrumbs.push(Breadcrumb {+                label: segment.to_string(),+                url: format!("{b}/{name}/commits/{}/tree/{accumulated}", params.sha),+            });+        }+    }++    Ok(BlameView {+        repo: name,+        sha: params.sha,+        path: params.path,+        tree_url,+        breadcrumbs,+        lines,+    })+}
Atemplates/blame.html
+{% extends "base.html" %}++{% block title %}blame {{ path }} - {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+  <li><a href="{{ crate::base_url() }}/">{{ crate::hostname() }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}">{{ repo }}</a></li>+  <li><a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ sha }}">{{ sha[..8] }}</a></li>+  {% for crumb in breadcrumbs %}+  <li>{% if loop.last %}{{ crumb.label }}{% else %}<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% endif %}</li>+  {% endfor %}+</ul>+{% endblock %}++{% block content %}+<div class="file-view">+  <div class="repo-nav">+    <a href="{{ tree_url }}" class="repo-nav-item">+      <svg class="summary-icon"><use href="#icon-file"/></svg>+      <span>File</span>+      <span class="repo-nav-item-arrow">[&gt;]</span>+    </a>+  </div>+  <div class="blame-wrap">+  <table class="blame-table">+    {% for line in lines %}+    <tr class="blame-row">+      <td class="blame-sha"><a href="{{ crate::base_url() }}/{{ repo }}/commits/{{ line.sha }}" style="color: {{ line.color }}">{{ line.short_sha }}</a></td>+      <td class="blame-linenum">{{ line.num }}</td>+      <td class="blame-code">{{ line.content }}</td>+    </tr>+    {% endfor %}+  </table>+  </div>+</div>+{% endblock %}