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
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>[>]</span></a></li>+ <li><a href="{{ crate::base_url() }}/{{ name }}/branches" class="see-all"><span>see all {{ branches.len() }} branches</span><span>[>]</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>[>]</span></a></li>+ <li><a href="{{ crate::base_url() }}/{{ name }}/tags" class="see-all"><span>see all {{ tags.len() }} tags</span><span>[>]</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">[>]</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">[>]</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">[>]</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, ¶ms.sha, &resolved_path);+ let b = crate::base_url();+ let breadcrumbs = build_breadcrumbs(&b, &name, ¶ms.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(¶ms.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">[>]</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 %}