max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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, ¬es_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 }} · {{ a.when }} | |
| 47 | - | {% else %} | |
| 48 | - | edited since {{ a.short_commit }} · {{ 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 }} · {{ a.when }} | |
| 14 | + | {% else %} | |
| 15 | + | edited since {{ a.short_commit }} · {{ 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 %} |