Skip to main content

max / makenotwork

server: add a Notes tab to the git browser Third slice of P1. /git/{owner}/{repo}/notes lists every refs/notes/* namespace with its note count and shows a feed of the annotations in one of them, newest first. The namespace segment is a wildcard capture because a notes ref carries slashes (mnw/builds), the same reason the tree routes use one. A namespace named in the URL that does not exist is a 404 rather than a quiet fall back to the default one, which would show a reader notes they did not ask for under a URL that lied. A repository with no notes at all is a different case and keeps the tab, with an empty state saying what notes are and how to push them, since a feature nobody can find is not shipped. Targets that are not commits still get a row. Notes annotate blobs and tags as readily as commits, and dropping those would leave the feed disagreeing with the count printed beside it. They link nowhere, because there is no page to link to yet. The feed is capped at 500 rows. Ordering by time means reading a commit header per annotated object, so the cost is the tree size rather than the page size, and a cap is the honest answer until P4's Postgres index gives the feed something to page against. The page prints the total beside the rows so the cap is visible when it bites. Counts and the feed both read through the NotesCache added in df407bfd, so the tab costs one tree walk per namespace per ref tip.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 19:35 UTC
Signed with PGP, not checked
Commit: ecd6151eaa27c01b69d634731064e4c4a0dc8fc8
Parent: df407bf
8 files changed, +426 insertions, -2 deletions
@@ -7183,6 +7183,20 @@
7183 7183 }
7184 7184 .git-note-namespace { font-family: var(--font-mono); }
7185 7185 .git-note-attribution { opacity: 0.6; }
7186 + .git-note-namespaces {
7187 + display: flex;
7188 + flex-wrap: wrap;
7189 + gap: var(--gap-group);
7190 + padding: var(--gap-section) 0;
7191 + border-bottom: 1px solid var(--border);
7192 + margin-bottom: var(--gap-section);
7193 + font-size: var(--text-note);
7194 + }
7195 + .git-note-namespaces a { color: var(--content); text-decoration: none; opacity: 0.6; }
7196 + .git-note-namespaces a:hover { opacity: 1; }
7197 + .git-note-namespaces a.is-selected { opacity: 1; text-decoration: underline; }
7198 + .git-note-target-kind { opacity: 0.6; }
7199 + .git-note-feed-cap { font-size: var(--text-fine); opacity: 0.6; padding-top: var(--gap-section); }
7186 7200 .git-note-badge {
7187 7201 font-size: var(--text-fine);
7188 7202 padding: var(--gap-bound) var(--gap-peer);
@@ -273,6 +273,7 @@
273 273 GitFileTemplate,
274 274 GitCommitsTemplate,
275 275 GitCommitDetailTemplate,
276 + GitNotesTemplate,
276 277 GitBlameTemplate,
277 278 GitUserReposTemplate,
278 279 GitExploreTemplate,
@@ -10,6 +10,7 @@
10 10 <nav class="git-nav-links">
11 11 <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}"{% if active_tab == "files" %} class="is-selected"{% endif %}>Files</a>
12 12 <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}"{% if active_tab == "commits" || active_tab == "commit" %} class="is-selected"{% endif %}>Commits</a>
13 + <a href="/git/{{ owner }}/{{ repo_name }}/notes"{% if active_tab == "notes" %} class="is-selected"{% endif %}>Notes</a>
13 14 <a href="/git/{{ owner }}/{{ repo_name }}/issues"{% if active_tab == "issues" %} class="is-selected"{% endif %}>Issues{% if open_issue_count > 0 %} ({{ open_issue_count }}){% endif %}</a>
14 15 {% if is_owner %}
15 16 <a href="/git/{{ owner }}/{{ repo_name }}/settings"{% if active_tab == "settings" %} class="is-selected"{% endif %}>Settings</a>
@@ -21,7 +21,7 @@
21 21 helpers::get_csrf_token,
22 22 templates::{
23 23 GitBlameTemplate, GitCommitDetailTemplate, GitCommitsTemplate, GitExploreTemplate,
24 - GitFileLogTemplate, GitFileTemplate, GitRepoTemplate, GitTreeTemplate,
24 + GitFileLogTemplate, GitFileTemplate, GitNotesTemplate, GitRepoTemplate, GitTreeTemplate,
25 25 GitUserReposTemplate,
26 26 },
27 27 };
@@ -440,6 +440,107 @@
440 440 })
441 441 }
442 442
443 + /// `GET /git/{owner}/{repo}/notes`: the repo's notes, default namespace first.
444 + #[tracing::instrument(skip_all, name = "git::notes_tab")]
445 + pub(super) async fn notes_tab(
446 + State(db): State<PgPool>,
447 + State(config): State<Config>,
448 + State(git_state): State<Git>,
449 + session: Session,
450 + MaybeUserVerified(maybe_user): MaybeUserVerified,
451 + Path((owner, repo_name)): Path<(String, String)>,
452 + ) -> Result<impl IntoResponse> {
453 + notes_tab_inner(
454 + db, config, git_state, session, maybe_user, owner, repo_name, None,
455 + )
456 + .await
457 + }
458 +
459 + /// `GET /git/{owner}/{repo}/notes/{*namespace}`: one namespace's notes.
460 + ///
461 + /// The namespace is a wildcard capture because a notes ref path carries slashes
462 + /// (`mnw/builds`), the same reason the tree routes use one.
463 + #[tracing::instrument(skip_all, name = "git::notes_tab_namespace")]
464 + pub(super) async fn notes_tab_namespace(
465 + State(db): State<PgPool>,
466 + State(config): State<Config>,
467 + State(git_state): State<Git>,
468 + session: Session,
469 + MaybeUserVerified(maybe_user): MaybeUserVerified,
470 + Path((owner, repo_name, namespace)): Path<(String, String, String)>,
471 + ) -> Result<impl IntoResponse> {
472 + notes_tab_inner(
473 + db,
474 + config,
475 + git_state,
476 + session,
477 + maybe_user,
478 + owner,
479 + repo_name,
480 + Some(namespace),
481 + )
482 + .await
483 + }
484 +
485 + #[allow(clippy::too_many_arguments)]
486 + async fn notes_tab_inner(
487 + db: PgPool,
488 + config: Config,
489 + git_state: Git,
490 + session: Session,
491 + maybe_user: Option<crate::auth::SessionUser>,
492 + owner: String,
493 + repo_name: String,
494 + namespace: Option<String>,
495 + ) -> Result<impl IntoResponse> {
496 + let resolved = resolve_repo(
497 + &db,
498 + &config,
499 + &owner,
500 + &repo_name,
501 + maybe_user.as_ref().map(|u| u.id),
502 + )
503 + .await?;
504 +
505 + let notes_cache = git_state.notes_cache;
506 + let repo_name_c = repo_name.clone();
507 + let namespace_c = namespace.clone();
508 + let (refs, info, page) = resolved
509 + .with_repo(move |gix_repo| {
510 + let refs = git::list_refs(gix_repo);
511 + let info = git::repo_info(gix_repo, &repo_name_c);
512 + // A namespace named in the URL that does not exist is a 404 rather
513 + // than a silent fallback to the default one, which would show a
514 + // reader notes they did not ask for under a URL that lied.
515 + let page = notes_view::notes_page(gix_repo, &notes_cache, namespace_c.as_deref())
516 + .ok_or(AppError::NotFound)?;
517 + Ok((refs, info, page))
518 + })
519 + .await?;
520 +
521 + let csrf_token = get_csrf_token(&session).await;
522 + let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
523 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id)
524 + .await
525 + .unwrap_or((0, 0));
526 +
527 + Ok(GitNotesTemplate {
528 + csrf_token,
529 + session_user: maybe_user,
530 + owner,
531 + repo_name,
532 + current_ref: info.default_branch,
533 + refs,
534 + namespaces: page.namespaces,
535 + selected: page.selected,
536 + rows: page.rows,
537 + total: page.total,
538 + open_issue_count,
539 + is_owner,
540 + active_tab: "notes",
541 + })
542 + }
543 +
443 544 /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}`: blame view.
444 545 #[tracing::instrument(skip_all, name = "git::blame_view")]
445 546 pub(super) async fn blame_view(
@@ -53,6 +53,13 @@
53 53 "/git/{owner}/{repo}/blame/{ref}/{*path}",
54 54 get(browsing::blame_view),
55 55 )
56 + .route("/git/{owner}/{repo}/notes", get(browsing::notes_tab))
57 + // A namespace is a ref path and carries slashes (`mnw/builds`), so the
58 + // segment is a wildcard capture like the tree routes use.
59 + .route(
60 + "/git/{owner}/{repo}/notes/{*namespace}",
61 + get(browsing::notes_tab_namespace),
62 + )
56 63 .route(
57 64 "/git/{owner}/{repo}/log/{ref}/{*path}",
58 65 get(browsing::file_log),
@@ -15,6 +15,8 @@
15 15
16 16 use std::collections::HashMap;
17 17
18 + use gix::bstr::ByteSlice;
19 +
18 20 use crate::git::notes::{self, Attribution, GixEngine, Oid};
19 21
20 22 /// How far back the attribution walk may look on a detail view. Bounded because
@@ -156,6 +158,174 @@
156 158 counts
157 159 }
158 160
161 + /// One namespace on the Notes tab.
162 + pub struct NamespaceSummary {
163 + pub name: String,
164 + pub count: usize,
165 + /// Whether this is the namespace the page is currently showing.
166 + pub is_current: bool,
167 + }
168 +
169 + /// One annotated object in the feed.
170 + pub struct AnnotationRow {
171 + pub target: String,
172 + pub short_target: String,
173 + /// The commit's subject line, or empty when the target is not a commit.
174 + pub summary: String,
175 + /// Commit time, or empty when the target is not a commit.
176 + pub when: String,
177 + /// Whether the target resolved to a commit, and so whether the row links to
178 + /// a commit page. Notes annotate blobs and trees too, and those have no page
179 + /// to point at.
180 + pub is_commit: bool,
181 + }
182 +
183 + /// The Notes tab: every namespace with its count, and a feed for one of them.
184 + pub struct NotesPage {
185 + pub namespaces: Vec<NamespaceSummary>,
186 + /// Namespace the feed belongs to. `None` for a repository with no notes.
187 + pub selected: Option<String>,
188 + pub rows: Vec<AnnotationRow>,
189 + /// Notes in the selected namespace, which exceeds `rows.len()` when the feed
190 + /// was capped.
191 + pub total: usize,
192 + }
193 +
194 + /// The feed reads one commit header per annotated object, since ordering by
195 + /// time is only possible after reading every timestamp. That is what bounds the
196 + /// feed rather than a page size with an offset, and it means a namespace past
197 + /// this size shows an arbitrary subset of its notes rather than the newest. The
198 + /// page prints the total beside the rows so the cap is visible when it bites;
199 + /// paging the feed properly wants the Postgres index P4 builds.
200 + const FEED_MAX_ROWS: usize = 500;
201 +
202 + /// Build the Notes tab for `repo`, showing `selected` or the first namespace.
203 + ///
204 + /// `Ok(None)` means the requested namespace does not exist, which the handler
205 + /// turns into a 404. A repository with no notes at all is not that case: it
206 + /// returns a page with no namespaces, so the tab explains itself rather than
207 + /// disappearing.
208 + pub fn notes_page(
209 + repo: &gix::Repository,
210 + cache: &notes::NotesCache,
211 + selected: Option<&str>,
212 + ) -> Option<NotesPage> {
213 + let engine = GixEngine::new(repo);
214 + let namespaces = match notes::list_namespaces(&engine) {
215 + Ok(ns) => ns,
216 + Err(e) => {
217 + tracing::warn!(error = %e, "listing notes namespaces failed");
218 + Vec::new()
219 + }
220 + };
221 +
222 + let current = match selected {
223 + Some(name) => Some(namespaces.iter().find(|ns| ns.name == name)?),
224 + None => namespaces.first(),
225 + };
226 +
227 + let repo_key = repo.path().to_string_lossy().into_owned();
228 + let (rows, total) = match current {
229 + Some(ns) => match cache.flattened(&engine, &repo_key, ns) {
230 + Ok(map) => {
231 + let total = map.len();
232 + let mut rows: Vec<AnnotationRow> = map
233 + .keys()
234 + .take(FEED_MAX_ROWS)
235 + .map(|target| annotation_row(repo, *target))
236 + .collect();
237 + // Newest first, and objects that are not commits last: they have
238 + // no time to sort by and no page to link to.
239 + rows.sort_by(|a, b| b.when.cmp(&a.when).then_with(|| a.target.cmp(&b.target)));
240 + (rows, total)
241 + }
242 + Err(e) => {
243 + tracing::warn!(namespace = %ns.name, error = %e, "reading the notes tree failed");
244 + (Vec::new(), 0)
245 + }
246 + },
247 + None => (Vec::new(), 0),
248 + };
249 +
250 + let selected = current.map(|ns| ns.name.clone());
251 + Some(NotesPage {
252 + namespaces: namespaces
253 + .iter()
254 + .map(|ns| NamespaceSummary {
255 + count: count_in(&engine, cache, &repo_key, ns),
256 + is_current: Some(&ns.name) == selected.as_ref(),
257 + name: ns.name.clone(),
258 + })
259 + .collect(),
260 + selected,
261 + rows,
262 + total,
263 + })
264 + }
265 +
266 + /// Notes in a namespace, through the cache so the tab's counts cost one walk
267 + /// per namespace per ref tip rather than one per page view.
268 + fn count_in(
269 + engine: &GixEngine<'_>,
270 + cache: &notes::NotesCache,
271 + repo_key: &str,
272 + ns: &notes::Namespace,
273 + ) -> usize {
274 + cache
275 + .flattened(engine, repo_key, ns)
276 + .map_or(0, |map| map.len())
277 + }
278 +
279 + /// Describe one annotated object. A target that is not a commit still gets a
280 + /// row: notes annotate blobs and trees too, and dropping them would make the
281 + /// feed disagree with the count beside it.
282 + fn annotation_row(repo: &gix::Repository, target: Oid) -> AnnotationRow {
283 + let hex = target.to_hex();
284 + let short_target = target.to_short_hex(SHORT_OID_LEN);
285 +
286 + let commit = gix::ObjectId::from_hex(hex.as_bytes())
287 + .ok()
288 + .and_then(|oid| repo.find_commit(oid).ok());
289 + let Some(commit) = commit else {
290 + return AnnotationRow {
291 + target: hex,
292 + short_target,
293 + summary: String::new(),
294 + when: String::new(),
295 + is_commit: false,
296 + };
297 + };
298 +
299 + let summary = commit
300 + .message_raw()
301 + .map(|m| m.to_str_lossy().into_owned())
302 + .unwrap_or_default()
303 + .lines()
304 + .next()
305 + .unwrap_or("")
306 + .to_string();
307 + let seconds = commit
308 + .committer()
309 + .ok()
310 + .and_then(|c| c.time().ok())
311 + .map(|t| t.seconds)
312 + .unwrap_or_default();
313 + // Sortable and readable at once: the feed orders on this string, so it has
314 + // to stay lexicographically ordered by time.
315 + let when = chrono::DateTime::from_timestamp(seconds, 0)
316 + .unwrap_or_default()
317 + .format("%Y-%m-%d %H:%M UTC")
318 + .to_string();
319 +
320 + AnnotationRow {
321 + target: hex,
322 + short_target,
323 + summary,
324 + when,
325 + is_commit: true,
326 + }
327 + }
328 +
159 329 fn render_attribution(a: Attribution) -> NoteAttribution {
160 330 NoteAttribution {
161 331 short_commit: a.note_commit.to_short_hex(SHORT_OID_LEN),
@@ -265,6 +435,57 @@
265 435 assert!(annotation_counts(&repo, &notes::NotesCache::default(), &[]).is_empty());
266 436 }
267 437
438 + #[test]
439 + fn the_tab_defaults_to_the_first_namespace_and_counts_the_rest() {
440 + let (_tmp, repo) = annotated_repo();
441 + let page = notes_page(&repo, &notes::NotesCache::default(), None).expect("a page");
442 +
443 + let listed: Vec<(&str, usize)> = page
444 + .namespaces
445 + .iter()
446 + .map(|ns| (ns.name.as_str(), ns.count))
447 + .collect();
448 + assert_eq!(listed, [("commits", 1), ("mnw/builds", 1)]);
449 + assert_eq!(page.selected.as_deref(), Some("commits"));
450 + assert_eq!(page.total, 1);
451 + assert!(page.namespaces[0].is_current);
452 + assert!(!page.namespaces[1].is_current);
453 + }
454 +
455 + #[test]
456 + fn a_namespace_with_slashes_is_selectable_by_name() {
457 + let (_tmp, repo) = annotated_repo();
458 + let page =
459 + notes_page(&repo, &notes::NotesCache::default(), Some("mnw/builds")).expect("a page");
460 +
461 + assert_eq!(page.selected.as_deref(), Some("mnw/builds"));
462 + assert_eq!(page.rows.len(), 1);
463 + // The fixture annotates a synthetic id, so the target is not a commit.
464 + // The row survives anyway: dropping it would make the feed disagree with
465 + // the count printed beside it.
466 + assert!(!page.rows[0].is_commit);
467 + assert_eq!(page.rows[0].target, TARGET);
468 + }
469 +
470 + #[test]
471 + fn a_namespace_that_does_not_exist_is_not_a_silent_fallback() {
472 + let (_tmp, repo) = annotated_repo();
473 + assert!(notes_page(&repo, &notes::NotesCache::default(), Some("nope")).is_none());
474 + }
475 +
476 + #[test]
477 + fn an_unannotated_repository_still_has_a_tab() {
478 + let tmp = tempfile::TempDir::new().unwrap();
479 + let path = tmp.path().join("owner").join("bare.git");
480 + std::fs::create_dir_all(&path).unwrap();
481 + let repo = gix::init_bare(&path).unwrap();
482 +
483 + let page = notes_page(&repo, &notes::NotesCache::default(), None).expect("a page");
484 + assert!(page.namespaces.is_empty());
485 + assert!(page.selected.is_none());
486 + assert!(page.rows.is_empty());
487 + }
488 +
268 489 #[test]
269 490 fn an_unannotated_target_and_a_malformed_one_both_render_nothing() {
270 491 let (_tmp, repo) = annotated_repo();
@@ -7,7 +7,7 @@
7 7
8 8 use crate::auth::SessionUser;
9 9 use crate::git;
10 - use crate::routes::git::notes_view::CommitNote;
10 + use crate::routes::git::notes_view::{AnnotationRow, CommitNote, NamespaceSummary};
11 11 use crate::types::{Item, Project, Version};
12 12
13 13 use super::super::CsrfTokenOption;
@@ -139,6 +139,29 @@
139 139 pub active_tab: &'static str,
140 140 }
141 141
142 + /// The repo's Notes tab: every `refs/notes/*` namespace with its count, and a
143 + /// feed of the annotations in the selected one.
144 + #[derive(Template)]
145 + #[template(path = "pages/git/notes.html")]
146 + pub struct GitNotesTemplate {
147 + pub csrf_token: CsrfTokenOption,
148 + pub session_user: Option<SessionUser>,
149 + pub owner: String,
150 + pub repo_name: String,
151 + pub current_ref: String,
152 + pub refs: Vec<git::RefInfo>,
153 + pub namespaces: Vec<NamespaceSummary>,
154 + /// Namespace the feed belongs to. `None` for a repository with no notes.
155 + pub selected: Option<String>,
156 + pub rows: Vec<AnnotationRow>,
157 + /// Notes in the selected namespace, which exceeds `rows.len()` when the feed
158 + /// was capped.
159 + pub total: usize,
160 + pub open_issue_count: i64,
161 + pub is_owner: bool,
162 + pub active_tab: &'static str,
163 + }
164 +
142 165 /// Blame view for a single file.
143 166 #[derive(Template)]
144 167 #[template(path = "pages/git/blame.html")]
@@ -1,0 +1,56 @@
1 + {% extends "base.html" %}
2 + {%- import "partials/_ui.html" as ui -%}
3 +
4 + {% block title %}Notes - {{ repo_name }} - Git - Makenotwork{% endblock %}
5 + {% block body_attrs %} class="padded-page"{% endblock %}
6 +
7 + {% block content %}
8 + {% include "partials/site_header.html" %}
9 +
10 + <h1 class="git-repo-name">
11 + <a href="/git/{{ owner }}">{{ owner }}</a>
12 + <span class="sep">/</span>
13 + <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a>
14 + </h1>
15 +
16 + {% include "partials/git_nav.html" %}
17 +
18 + {% if namespaces.is_empty() %}
19 + {% call ui::empty_state("", "No notes in this repository. Notes are git objects on refs/notes/*; push them with git push origin refs/notes/*.") %}{% endcall %}
20 + {% else %}
21 + <nav class="git-note-namespaces">
22 + {% for ns in namespaces %}
23 + <a href="/git/{{ owner }}/{{ repo_name }}/notes/{{ ns.name }}"{% if ns.is_current %} class="is-selected"{% endif %}>{{ ns.name }} ({{ ns.count }})</a>
24 + {% endfor %}
25 + </nav>
26 +
27 + <ul class="git-commit-list">
28 + {% for row in rows %}
29 + <li class="git-commit">
30 + <p class="git-commit-message">
31 + {% if row.is_commit %}
32 + <span class="summary">{{ row.summary }}</span>
33 + {% else %}
34 + <span class="summary git-note-target-kind">Annotated object</span>
35 + {% endif %}
36 + </p>
37 + <div class="git-commit-meta">
38 + {% if row.is_commit %}
39 + <span>{{ row.when }}</span>
40 + <span class="git-commit-oid">
41 + <a href="/git/{{ owner }}/{{ repo_name }}/commit/{{ row.target }}#notes">{{ row.short_target }}</a>
42 + </span>
43 + {% else %}
44 + <span class="git-commit-oid">{{ row.short_target }}</span>
45 + {% endif %}
46 + </div>
47 + </li>
48 + {% endfor %}
49 + </ul>
50 +
51 + {% if total > rows.len() %}
52 + <p class="git-note-feed-cap">Showing {{ rows.len() }} of {{ total }} annotations.</p>
53 + {% endif %}
54 + {% endif %}
55 +
56 + {% endblock %}