Skip to main content

max / makenotwork

server: surface notes on the file and blame views Last slice of P1. The file view shows the notes on the blob it is displaying, and the blame view marks any line whose commit carries one. A note on a file annotates the blob rather than the commit that happens to contain it: the same content at the same path across a hundred commits is one object and carries one annotation, which is the behaviour git itself has and no forge surfaces. FileContent gained the blob id to make that lookup possible; re-hashing the content to find out which blob it is would be slower and a second opinion on something git already decided. Blame asks once per distinct commit rather than once per line, since a blame page is thousands of rows over a handful of commits. BlameLine gained the full commit id, which fixes a link that was already broken: the blame gutter pointed at /commit/<short oid>, and the commit page parses a full id and 404s on anything shorter. The short form stays as the label. The notes panel moved into partials/git_notes_panel.html now that the commit page and the file view both render it, so the two cannot drift.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 19:52 UTC
Signed with PGP, not checked
Commit: 0887db0ad88358ee38eb2dec61518273c25a206b
Parent: 64f9fad
9 files changed, +92 insertions, -33 deletions
@@ -553,6 +553,7 @@
553 553
554 554 result.push(BlameLine {
555 555 lineno,
556 + commit_oid: oid_str,
556 557 commit_short_oid: short_oid,
557 558 author_name,
558 559 time_formatted,
@@ -42,6 +42,9 @@
42 42
43 43 /// File content with metadata.
44 44 pub struct FileContent {
45 + /// Id of the blob itself. Notes can annotate a blob as readily as a commit,
46 + /// and this is what the file view looks one up by.
47 + pub oid: String,
45 48 pub content: String,
46 49 pub size: u64,
47 50 pub is_binary: bool,
@@ -152,6 +155,9 @@
152 155 /// A line of blame output.
153 156 pub struct BlameLine {
154 157 pub lineno: usize,
158 + /// Full id of the commit that last touched this line. The view links and
159 + /// looks notes up by this; the short form is display only.
160 + pub commit_oid: String,
155 161 pub commit_short_oid: String,
156 162 pub author_name: String,
157 163 pub time_formatted: String,
@@ -63,7 +63,8 @@
63 63 commit_oid: ObjectId,
64 64 path: &str,
65 65 ) -> Result<FileContent, GitError> {
66 - let content = read_blob_at(repo, commit_oid, path)?;
66 + let (oid, content) = read_blob_at(repo, commit_oid, path)?;
67 + let oid = oid.to_string();
67 68
68 69 let size = content.len() as u64;
69 70
@@ -73,6 +74,7 @@
73 74
74 75 if is_binary {
75 76 return Ok(FileContent {
77 + oid,
76 78 content: String::new(),
77 79 size,
78 80 is_binary: true,
@@ -81,6 +83,7 @@
81 83
82 84 if size > constants::GIT_MAX_FILE_SIZE_BYTES as u64 {
83 85 return Ok(FileContent {
86 + oid,
84 87 content: format!("File too large to display ({size} bytes)"),
85 88 size,
86 89 is_binary: false,
@@ -89,14 +92,23 @@
89 92
90 93 let text = String::from_utf8_lossy(&content).into_owned();
91 94 Ok(FileContent {
95 + oid,
92 96 content: text,
93 97 size,
94 98 is_binary: false,
95 99 })
96 100 }
97 101
98 - /// Read the raw bytes of a blob at `path` in the tree of `commit_oid`.
99 - fn read_blob_at(repo: &Repository, commit_oid: ObjectId, path: &str) -> Result<Vec<u8>, GitError> {
102 + /// Read a blob at `path` in the tree of `commit_oid`, with its id.
103 + ///
104 + /// The id is returned alongside the bytes because a note can annotate the blob,
105 + /// and re-hashing the content to find out which blob this is would be both
106 + /// slower and a second opinion on something git already decided.
107 + fn read_blob_at(
108 + repo: &Repository,
109 + commit_oid: ObjectId,
110 + path: &str,
111 + ) -> Result<(ObjectId, Vec<u8>), GitError> {
100 112 let commit = repo
101 113 .find_commit(commit_oid)
102 114 .map_err(|_| GitError::RefNotFound)?;
@@ -113,7 +125,8 @@
113 125 return Err(GitError::PathNotFound);
114 126 }
115 127
116 - Ok(object.detach().data)
128 + let object = object.detach();
129 + Ok((object.id, object.data))
117 130 }
118 131
119 132 /// Look for a README file at the tree root.
@@ -127,7 +140,7 @@
127 140 ];
128 141
129 142 for name in &readme_names {
130 - let Ok(content) = read_blob_at(repo, commit_oid, name) else {
143 + let Ok((_, content)) = read_blob_at(repo, commit_oid, name) else {
131 144 continue;
132 145 };
133 146
@@ -213,7 +213,11 @@
213 213 // one blocking-pool pass.
214 214 enum DirOrFile {
215 215 Dir(Vec<git::TreeItem>, Vec<git::RefInfo>),
216 - File(git::FileContent, Vec<git::RefInfo>),
216 + File(
217 + git::FileContent,
218 + Vec<git::RefInfo>,
219 + Vec<notes_view::CommitNote>,
220 + ),
217 221 }
218 222 let git_ref_c = git_ref.clone();
219 223 let path_c = path.clone();
@@ -224,14 +228,23 @@
224 228 Ok(tree_items) => Ok(DirOrFile::Dir(tree_items, git::list_refs(gix_repo))),
225 229 Err(_) => {
226 230 let file_content = git::read_file(gix_repo, commit_oid, &path_c)?;
227 - Ok(DirOrFile::File(file_content, git::list_refs(gix_repo)))
231 + // A note on a file is a note on the blob, not on the commit
232 + // that happens to contain it: the same content at the same
233 + // path across a hundred commits is one object and carries
234 + // one annotation.
235 + let notes = notes_view::notes_on_object(gix_repo, &file_content.oid);
236 + Ok(DirOrFile::File(
237 + file_content,
238 + git::list_refs(gix_repo),
239 + notes,
240 + ))
228 241 }
229 242 }
230 243 })
231 244 .await?;
232 245
233 246 // A directory listing renders and returns here; a file falls through.
234 - let (file_content, refs) = match dir_or_file {
247 + let (file_content, refs, notes) = match dir_or_file {
235 248 DirOrFile::Dir(tree_items, refs) => {
236 249 let breadcrumbs = build_breadcrumbs(&path);
237 250 let parent_path = parent_of(&path);
@@ -256,7 +269,7 @@
256 269 }
257 270 .into_response());
258 271 }
259 - DirOrFile::File(file_content, refs) => (file_content, refs),
272 + DirOrFile::File(file_content, refs, notes) => (file_content, refs, notes),
260 273 };
261 274
262 275 // File view.
@@ -293,6 +306,7 @@
293 306 breadcrumbs,
294 307 file_size,
295 308 line_count,
309 + notes,
296 310 is_binary: file_content.is_binary,
297 311 highlighted_lines,
298 312 open_issue_count,
@@ -546,6 +560,7 @@
546 560 pub(super) async fn blame_view(
547 561 State(db): State<PgPool>,
548 562 State(config): State<Config>,
563 + State(git_state): State<Git>,
549 564 session: Session,
550 565 MaybeUserVerified(maybe_user): MaybeUserVerified,
551 566 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
@@ -560,12 +575,20 @@
560 575 .await?;
561 576 let git_ref_c = git_ref.clone();
562 577 let path_c = path.clone();
563 - let (blame_lines, refs) = resolved
578 + let notes_cache = git_state.notes_cache;
579 + let (blame_lines, refs, annotated) = resolved
564 580 .with_repo(move |gix_repo| {
565 581 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
566 582 let blame_lines = git::blame_file(gix_repo, commit_oid, &path_c)?;
567 583 let refs = git::list_refs(gix_repo);
568 - Ok((blame_lines, refs))
584 + // A blame page is one row per line but only a handful of distinct
585 + // commits, so ask about each commit once rather than once per line
586 + // it covers.
587 + let mut shown: Vec<String> = blame_lines.iter().map(|l| l.commit_oid.clone()).collect();
588 + shown.sort_unstable();
589 + shown.dedup();
590 + let annotated = notes_view::annotation_counts(gix_repo, &notes_cache, &shown);
591 + Ok((blame_lines, refs, annotated))
569 592 })
570 593 .await?;
571 594
@@ -589,6 +612,7 @@
589 612 filename,
590 613 breadcrumbs,
591 614 blame_lines,
615 + annotated,
592 616 open_issue_count,
593 617 is_owner,
594 618 active_tab: "files",
@@ -89,6 +89,9 @@
89 89 pub line_count: usize,
90 90 pub is_binary: bool,
91 91 pub highlighted_lines: Vec<String>,
92 + /// Notes on the blob this view is showing. A note on a file annotates the
93 + /// object, so it follows the content rather than the path.
94 + pub notes: Vec<CommitNote>,
92 95 pub open_issue_count: i64,
93 96 pub is_owner: bool,
94 97 pub active_tab: &'static str,
@@ -176,6 +179,9 @@
176 179 pub filename: String,
177 180 pub breadcrumbs: Vec<git::Breadcrumb>,
178 181 pub blame_lines: Vec<git::BlameLine>,
182 + /// Commits on this page that carry a note, mapped to how many namespaces
183 + /// annotate them. Asked once per distinct commit, not once per line.
184 + pub annotated: HashMap<String, usize>,
179 185 pub open_issue_count: i64,
180 186 pub is_owner: bool,
181 187 pub active_tab: &'static str,
@@ -39,7 +39,10 @@
39 39 {% for line in blame_lines %}
40 40 <tr{% if line.is_boundary %} class="git-blame-boundary"{% endif %}>
41 41 <td class="git-blame-info">
42 - <a href="/git/{{ owner }}/{{ repo_name }}/commit/{{ line.commit_short_oid }}" title="{{ line.author_name }}, {{ line.time_formatted }}">{{ line.commit_short_oid }}</a>
42 + <a href="/git/{{ owner }}/{{ repo_name }}/commit/{{ line.commit_oid }}" title="{{ line.author_name }}, {{ line.time_formatted }}">{{ line.commit_short_oid }}</a>
43 + {% if let Some(n) = annotated.get(line.commit_oid) %}
44 + <a class="git-note-badge" href="/git/{{ owner }}/{{ repo_name }}/commit/{{ line.commit_oid }}#notes" title="{% if **n == 1 %}This commit has a note{% else %}This commit has {{ n }} notes{% endif %}">note</a>
45 + {% endif %}
43 46 </td>
44 47 <td class="git-blame-author">{{ line.author_name }}</td>
45 48 <td class="git-blame-date">{{ line.time_formatted }}</td>
@@ -34,27 +34,7 @@
34 34 </div>
35 35 </div>
36 36
37 - {% if !notes.is_empty() %}
38 - <div class="git-notes" id="notes">
39 - {% for note in notes %}
40 - <div class="git-note">
41 - <div class="git-note-header">
42 - <span class="git-note-namespace">{{ note.namespace }}</span>
43 - {% if let Some(a) = note.attribution %}
44 - <span class="git-note-attribution">
45 - {% if a.exact %}
46 - {{ a.by }} &middot; {{ a.when }}
47 - {% else %}
48 - edited since {{ a.short_commit }} &middot; {{ a.when }}
49 - {% endif %}
50 - </span>
51 - {% endif %}
52 - </div>
53 - <div class="git-note-body git-readme-body">{{ note.html|safe }}</div>
54 - </div>
55 - {% endfor %}
56 - </div>
57 - {% endif %}
37 + {% include "partials/git_notes_panel.html" %}
58 38
59 39 <div class="git-diff-stats">
60 40 {{ total_files }} file{% if total_files != 1 %}s{% endif %} changed,
@@ -36,6 +36,8 @@
36 36 </span>
37 37 </div>
38 38
39 + {% include "partials/git_notes_panel.html" %}
40 +
39 41 {% if is_binary %}
40 42 <div class="git-binary-notice">
41 43 Binary file ({{ file_size }}). <a href="/git/{{ owner }}/{{ repo_name }}/raw/{{ current_ref }}/{{ file_path }}">Download</a>
@@ -1,0 +1,24 @@
1 + {# Notes on the object a page is showing. Expects `notes: Vec<CommitNote>`.
2 + Shared by the commit page (notes on the commit) and the file view (notes on
3 + the blob), so the two cannot drift. #}
4 + {% if !notes.is_empty() %}
5 + <div class="git-notes" id="notes">
6 + {% for note in notes %}
7 + <div class="git-note">
8 + <div class="git-note-header">
9 + <span class="git-note-namespace">{{ note.namespace }}</span>
10 + {% if let Some(a) = note.attribution %}
11 + <span class="git-note-attribution">
12 + {% if a.exact %}
13 + {{ a.by }} &middot; {{ a.when }}
14 + {% else %}
15 + edited since {{ a.short_commit }} &middot; {{ a.when }}
16 + {% endif %}
17 + </span>
18 + {% endif %}
19 + </div>
20 + <div class="git-note-body git-readme-body">{{ note.html|safe }}</div>
21 + </div>
22 + {% endfor %}
23 + </div>
24 + {% endif %}