Skip to main content

max / makenotwork

server: read notes through the index, with search, paging and a feed The reads P4 exists for. Annotation counts on log, blame and file-log pages are one query where the index is warm; the Notes tab's namespace list, counts and feed come from it too, ordered by when each annotation was written rather than by the age of the commit it annotates. Every one of those falls back to walking the repository when the index has never seen it, which is a real state and not an error: the index is fed by a hook and two in-process writers, so a repository whose notes predate all three has rows nowhere until mnw-admin reindex-notes runs. Reporting it as unannotated would be a lie a reader cannot see through. The tab says which it is showing and hides the search box, the commits filter and the paging on the walked path rather than offering controls a tree walk cannot honour. Search is websearch_to_tsquery over the tsvector, so quoted phrases and -exclusions work the way people type them and a stray quote finds little instead of raising. The feed is RSS 2.0, not the atom the plan named. Every feed on the site is RSS 2.0 through crate::rss, which already handles the escaping and the control bytes a note can carry, and a second format is a second thing to keep correct for no reader's benefit. It carries no session, so a private repository is a 404 there even for its owner: a feed URL whose contents depend on who asked is the shape credentials leak through.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 01:22 UTC
Signed with PGP, not checked
Commit: a89063bf4518ac47ef1b0d02882a96962af07a70
Parent: a88b148
7 files changed, +500 insertions, -18 deletions
@@ -358,6 +358,10 @@
358 358 // Git source browser
359 359 pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit
360 360 pub const GIT_COMMITS_PER_PAGE: usize = 30;
361 +
362 + /// Annotations in the per-repository notes feed. A feed is a recent-news
363 + /// surface rather than an archive; the tab pages through the rest.
364 + pub const GIT_NOTES_FEED_ITEMS: i64 = 50;
361 365 pub const GIT_DIFF_MAX_FILES: usize = 20; // Inline diff hunks for first N files
362 366 pub const GIT_DIFF_MAX_LINES: usize = 500; // Per-file line cap for diff display
363 367 pub const GIT_REPOS_PER_PAGE: usize = 30;
@@ -7263,6 +7263,30 @@
7263 7263 .git-notes-fetch { padding-bottom: var(--gap-section); font-size: var(--text-note); }
7264 7264 .git-notes-fetch p { margin: 0 0 var(--gap-peer); }
7265 7265 .git-note-feed-cap { font-size: var(--text-fine); opacity: 0.6; padding-top: var(--gap-section); }
7266 + .git-note-search {
7267 + display: flex;
7268 + flex-wrap: wrap;
7269 + align-items: center;
7270 + gap: var(--gap-group);
7271 + padding-bottom: var(--gap-section);
7272 + font-size: var(--text-note);
7273 + }
7274 + .git-note-search input[type="search"] { flex: 1 1 16rem; }
7275 + .git-note-search label { display: flex; align-items: center; gap: var(--gap-bound); opacity: 0.8; }
7276 + .git-note-excerpt {
7277 + margin: var(--gap-bound) 0 0;
7278 + font-size: var(--text-note);
7279 + opacity: 0.7;
7280 + }
7281 + .git-note-paging {
7282 + display: flex;
7283 + gap: var(--gap-group);
7284 + align-items: center;
7285 + padding-top: var(--gap-section);
7286 + font-size: var(--text-note);
7287 + }
7288 + .git-note-paging span { font-size: var(--text-fine); opacity: 0.6; }
7289 + .git-note-feed-link { padding-top: var(--gap-peer); font-size: var(--text-fine); }
7266 7290 .git-note-badge {
7267 7291 font-size: var(--text-fine);
7268 7292 padding: var(--gap-bound) var(--gap-peer);
@@ -351,7 +351,7 @@
351 351
352 352 let git_ref_c = git_ref.clone();
353 353 let notes_cache = git_state.notes_cache;
354 - let (refs, commits, has_more, annotated) = resolved
354 + let (refs, commits, has_more, page_oids) = resolved
355 355 .with_repo(move |gix_repo| {
356 356 let refs = git::list_refs(gix_repo);
357 357 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
@@ -361,11 +361,14 @@
361 361 let has_more = commits.len() > limit;
362 362 commits.truncate(limit);
363 363 let page_oids: Vec<String> = commits.iter().map(|c| c.oid.clone()).collect();
364 - let annotated = notes_view::annotation_counts(gix_repo, &notes_cache, &page_oids);
365 - Ok((refs, commits, has_more, annotated))
364 + Ok((refs, commits, has_more, page_oids))
366 365 })
367 366 .await?;
368 367
368 + // Asked after the repository work rather than inside it: the index answers
369 + // this in one query and does not need the repository open at all.
370 + let annotated = notes_view::counts_for_page(&db, &resolved, notes_cache, page_oids).await;
371 +
369 372 let csrf_token = get_csrf_token(&session).await;
370 373 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
371 374
@@ -493,9 +496,10 @@
493 496 session: Session,
494 497 MaybeUserVerified(maybe_user): MaybeUserVerified,
495 498 Path((owner, repo_name)): Path<(String, String)>,
499 + ValidatedQuery(query): ValidatedQuery<NotesQuery>,
496 500 ) -> Result<impl IntoResponse> {
497 501 notes_tab_inner(
498 - db, config, git_state, session, maybe_user, owner, repo_name, None,
502 + db, config, git_state, session, maybe_user, owner, repo_name, None, query,
499 503 )
500 504 .await
501 505 }
@@ -512,6 +516,7 @@
512 516 session: Session,
513 517 MaybeUserVerified(maybe_user): MaybeUserVerified,
514 518 Path((owner, repo_name, namespace)): Path<(String, String, String)>,
519 + ValidatedQuery(query): ValidatedQuery<NotesQuery>,
515 520 ) -> Result<impl IntoResponse> {
516 521 notes_tab_inner(
517 522 db,
@@ -522,10 +527,24 @@
522 527 owner,
523 528 repo_name,
524 529 Some(namespace),
530 + query,
525 531 )
526 532 .await
527 533 }
528 534
535 + /// What the Notes tab was asked to show. Every field is a filter over the
536 + /// index; a repository the index has never seen ignores all of them, because
537 + /// walking the tree can answer none of them cheaply and a control that silently
538 + /// did nothing would be worse than one that is not offered.
539 + #[derive(Deserialize)]
540 + pub(super) struct NotesQuery {
541 + #[serde(default)]
542 + q: String,
543 + #[serde(default)]
544 + commits: Option<String>,
545 + page: Option<usize>,
546 + }
547 +
529 548 #[allow(clippy::too_many_arguments)]
530 549 async fn notes_tab_inner(
531 550 db: PgPool,
@@ -536,6 +555,7 @@
536 555 owner: String,
537 556 repo_name: String,
538 557 namespace: Option<String>,
558 + query: NotesQuery,
539 559 ) -> Result<impl IntoResponse> {
540 560 let resolved = resolve_repo(
541 561 &db,
@@ -546,21 +566,40 @@
546 566 )
547 567 .await?;
548 568
569 + let request = notes_view::NotesRequest {
570 + namespace: namespace.clone(),
571 + search: query.q,
572 + commits_only: query.commits.is_some(),
573 + page: query.page.unwrap_or(1).clamp(1, 10_000),
574 + };
575 + let indexed = notes_view::indexed_notes_page(&db, resolved.db_repo.id, &request).await?;
576 +
549 577 let notes_cache = git_state.notes_cache;
550 578 let repo_name_c = repo_name.clone();
551 579 let namespace_c = namespace.clone();
552 - let (refs, info, page) = resolved
580 + let have_index = indexed.is_some();
581 + let (refs, info, walked) = resolved
553 582 .with_repo(move |gix_repo| {
554 583 let refs = git::list_refs(gix_repo);
555 584 let info = git::repo_info(gix_repo, &repo_name_c);
556 - // A namespace named in the URL that does not exist is a 404 rather
585 + // Walked only when the index has nothing for this repository. A
586 + // namespace named in the URL that does not exist is a 404 rather
557 587 // than a silent fallback to the default one, which would show a
558 588 // reader notes they did not ask for under a URL that lied.
559 - let page = notes_view::notes_page(gix_repo, &notes_cache, namespace_c.as_deref())
560 - .ok_or(AppError::NotFound)?;
561 - Ok((refs, info, page))
589 + let walked = if have_index {
590 + None
591 + } else {
592 + Some(
593 + notes_view::notes_page(gix_repo, &notes_cache, namespace_c.as_deref())
594 + .ok_or(AppError::NotFound)?,
595 + )
596 + };
597 + Ok((refs, info, walked))
562 598 })
563 599 .await?;
600 + let page = indexed
601 + .or(walked)
602 + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("no notes page was built")))?;
564 603
565 604 let csrf_token = get_csrf_token(&session).await;
566 605 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
@@ -579,12 +618,97 @@
579 618 selected: page.selected,
580 619 rows: page.rows,
581 620 total: page.total,
621 + search: page.search,
622 + commits_only: page.commits_only,
623 + page_number: page.page,
624 + has_more: page.has_more,
625 + from_index: page.from_index,
582 626 open_issue_count,
583 627 is_owner,
584 628 active_tab: "notes",
585 629 })
586 630 }
587 631
632 + #[derive(Deserialize)]
633 + pub(super) struct NotesFeedQuery {
634 + namespace: Option<String>,
635 + }
636 +
637 + /// `GET /git/{owner}/{repo}/notes.rss`: annotations as a feed.
638 + ///
639 + /// RSS 2.0 rather than the atom the plan named. Every other feed on the site is
640 + /// RSS 2.0 through `crate::rss`, which already handles the escaping and the
641 + /// control bytes a note can carry, and a second format would be a second thing
642 + /// to keep correct for no reader's benefit.
643 + ///
644 + /// No session, so a private repository is a 404 here even for its owner. A feed
645 + /// is fetched by a reader with no cookies, and the alternative is a URL whose
646 + /// contents depend on who asked, which is the shape credentials leak through.
647 + #[tracing::instrument(skip_all, name = "git::notes_feed")]
648 + pub(super) async fn notes_feed(
649 + State(db): State<PgPool>,
650 + State(config): State<Config>,
651 + Path((owner, repo_name)): Path<(String, String)>,
652 + ValidatedQuery(query): ValidatedQuery<NotesFeedQuery>,
653 + ) -> Result<Response> {
654 + let resolved = resolve_repo(&db, &config, &owner, &repo_name, None).await?;
655 +
656 + // Index-only. Walking the repository could produce the rows but not the
657 + // annotation times the feed is ordered by, and a feed ordered by the age of
658 + // the annotated commit would replay a repository's whole history to a
659 + // subscriber the first time somebody annotated an old commit.
660 + let notes = db::git_notes::feed(
661 + &db,
662 + resolved.db_repo.id,
663 + query.namespace.as_deref(),
664 + false,
665 + constants::GIT_NOTES_FEED_ITEMS,
666 + 0,
667 + )
668 + .await?;
669 +
670 + let items: Vec<crate::rss::FeedItem> = notes
671 + .into_iter()
672 + .map(|note| {
673 + let short = note.target_oid.chars().take(8).collect::<String>();
674 + let title = if note.target_summary.is_empty() {
675 + format!("Note on {short}")
676 + } else {
677 + format!("{} ({short})", note.target_summary)
678 + };
679 + crate::rss::FeedItem {
680 + title,
681 + link: format!(
682 + "{}/git/{owner}/{repo_name}/commit/{}#notes",
683 + config.host_url, note.target_oid
684 + ),
685 + description: note.content,
686 + pub_date: note.updated_at,
687 + // The blob is in the guid on purpose: editing a note is new
688 + // news, and a guid that named only the target would make an
689 + // edit invisible to every reader who already saw the original.
690 + guid: format!("{}:{}:{}", note.namespace, note.target_oid, note.blob_oid),
691 + }
692 + })
693 + .collect();
694 +
695 + let xml = crate::rss::render_feed_custom(
696 + &format!("{owner}/{repo_name} notes"),
697 + &format!("{}/git/{owner}/{repo_name}/notes", config.host_url),
698 + "Annotations on this repository, from refs/notes/*",
699 + &items,
700 + );
701 +
702 + Ok((
703 + [(
704 + axum::http::header::CONTENT_TYPE,
705 + "application/rss+xml; charset=utf-8",
706 + )],
707 + xml,
708 + )
709 + .into_response())
710 + }
711 +
588 712 /// `GET /git/{owner}/{repo}/tags`: every tag, with annotation bodies.
589 713 #[tracing::instrument(skip_all, name = "git::tags_tab")]
590 714 pub(super) async fn tags_tab(
@@ -710,7 +834,7 @@
710 834 let git_ref_c = git_ref.clone();
711 835 let path_c = path.clone();
712 836 let notes_cache = git_state.notes_cache;
713 - let (blame_lines, refs, annotated) = resolved
837 + let (blame_lines, refs, shown) = resolved
714 838 .with_repo(move |gix_repo| {
715 839 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
716 840 let blame_lines = git::blame_file(gix_repo, commit_oid, &path_c)?;
@@ -721,11 +845,12 @@
721 845 let mut shown: Vec<String> = blame_lines.iter().map(|l| l.commit_oid.clone()).collect();
722 846 shown.sort_unstable();
723 847 shown.dedup();
724 - let annotated = notes_view::annotation_counts(gix_repo, &notes_cache, &shown);
725 - Ok((blame_lines, refs, annotated))
848 + Ok((blame_lines, refs, shown))
726 849 })
727 850 .await?;
728 851
852 + let annotated = notes_view::counts_for_page(&db, &resolved, notes_cache, shown).await;
853 +
729 854 let filename = path.rsplit('/').next().unwrap_or(&path).to_string();
730 855 let breadcrumbs = build_breadcrumbs(&path);
731 856
@@ -847,7 +972,7 @@
847 972 let git_ref_c = git_ref.clone();
848 973 let path_c = path.clone();
849 974 let notes_cache = git_state.notes_cache;
850 - let (refs, commits, has_more, annotated) = resolved
975 + let (refs, commits, has_more, page_oids) = resolved
851 976 .with_repo(move |gix_repo| {
852 977 let refs = git::list_refs(gix_repo);
853 978 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
@@ -862,11 +987,12 @@
862 987 let has_more = commits.len() > limit;
863 988 commits.truncate(limit);
864 989 let page_oids: Vec<String> = commits.iter().map(|c| c.oid.clone()).collect();
865 - let annotated = notes_view::annotation_counts(gix_repo, &notes_cache, &page_oids);
866 - Ok((refs, commits, has_more, annotated))
990 + Ok((refs, commits, has_more, page_oids))
867 991 })
868 992 .await?;
869 993
994 + let annotated = notes_view::counts_for_page(&db, &resolved, notes_cache, page_oids).await;
995 +
870 996 let filename = path.rsplit('/').next().unwrap_or(&path).to_string();
871 997 let breadcrumbs = build_breadcrumbs(&path);
872 998 let csrf_token = get_csrf_token(&session).await;
@@ -60,6 +60,10 @@
60 60 .route("/git/{owner}/{repo}/tags", get(browsing::tags_tab))
61 61 .route("/git/{owner}/{repo}/replace", get(browsing::replace_tab))
62 62 .route("/git/{owner}/{repo}/notes", get(browsing::notes_tab))
63 + // `notes.rss` rather than `notes/rss`, which the namespace wildcard
64 + // below would otherwise claim, and which would make a namespace called
65 + // `rss` unreachable.
66 + .route("/git/{owner}/{repo}/notes.rss", get(browsing::notes_feed))
63 67 // A namespace is a ref path and carries slashes (`mnw/builds`), so the
64 68 // segment is a wildcard capture like the tree routes use.
65 69 .route(
@@ -164,6 +164,51 @@
164 164 counts
165 165 }
166 166
167 + /// Annotation counts for a page of commits, from the index where there is one.
168 + ///
169 + /// The index answers this in one query; the repository answers it by flattening
170 + /// every namespace's tree, which the tip-keyed cache makes cheap on a warm
171 + /// process and not on a cold one. Both are correct, so a cold index degrades the
172 + /// cost rather than the page.
173 + ///
174 + /// Failures on either path yield no counts and a warning. A missing badge is
175 + /// worth less than a commit log, and this is the one place notes could take one
176 + /// down.
177 + pub(super) async fn counts_for_page(
178 + db: &sqlx::PgPool,
179 + resolved: &super::ResolvedRepo,
180 + cache: std::sync::Arc<notes::NotesCache>,
181 + targets: Vec<String>,
182 + ) -> HashMap<String, usize> {
183 + if targets.is_empty() {
184 + return HashMap::new();
185 + }
186 +
187 + match crate::db::git_notes::is_indexed(db, resolved.db_repo.id).await {
188 + Ok(true) => {
189 + match crate::db::git_notes::annotation_counts(db, resolved.db_repo.id, &targets).await {
190 + Ok(counts) => {
191 + return counts
192 + .into_iter()
193 + .map(|(oid, n)| (oid, n.max(0) as usize))
194 + .collect();
195 + }
196 + Err(e) => tracing::warn!(error = %e, "indexed annotation counts failed"),
197 + }
198 + }
199 + Ok(false) => {}
200 + Err(e) => tracing::warn!(error = %e, "could not tell whether the notes index is warm"),
201 + }
202 +
203 + resolved
204 + .with_repo(move |repo| Ok(annotation_counts(repo, &cache, &targets)))
205 + .await
206 + .unwrap_or_else(|e| {
207 + tracing::warn!(error = %e, "walking the repository for annotation counts failed");
208 + HashMap::new()
209 + })
210 + }
211 +
167 212 /// One namespace on the Notes tab.
168 213 pub struct NamespaceSummary {
169 214 pub name: String,
@@ -176,9 +221,17 @@
176 221 pub struct AnnotationRow {
177 222 pub target: String,
178 223 pub short_target: String,
224 + /// Namespace the note is in. Empty on the repository-backed feed, which
225 + /// only ever shows one namespace and prints its name above the rows.
226 + pub namespace: String,
227 + /// The first lines of the note itself. Empty on the repository-backed feed,
228 + /// which would need a blob read per row to fill it.
229 + pub excerpt: String,
179 230 /// The commit's subject line, or empty when the target is not a commit.
180 231 pub summary: String,
181 - /// Commit time, or empty when the target is not a commit.
232 + /// When the annotation was written, from the index. The repository-backed
233 + /// feed has no cheap way to know that and prints the commit's time instead,
234 + /// which is also what it orders by.
182 235 pub when: String,
183 236 /// Whether the target resolved to a commit, and so whether the row links to
184 237 /// a commit page. Notes annotate blobs and trees too, and those have no page
@@ -195,6 +248,18 @@
195 248 /// Notes in the selected namespace, which exceeds `rows.len()` when the feed
196 249 /// was capped.
197 250 pub total: usize,
251 + /// The search that produced these rows, echoed back into the box.
252 + pub search: String,
253 + /// Whether the feed is filtered to notes on commits.
254 + pub commits_only: bool,
255 + /// 1-based page of the feed.
256 + pub page: usize,
257 + /// Whether a further page exists.
258 + pub has_more: bool,
259 + /// Whether these rows came from the index. False means the repository was
260 + /// walked, which is correct but cannot search, page, or order by annotation
261 + /// time; the page says so rather than offering controls that would lie.
262 + pub from_index: bool,
198 263 }
199 264
200 265 /// The feed reads one commit header per annotated object, since ordering by
@@ -266,6 +331,11 @@
266 331 selected,
267 332 rows,
268 333 total,
334 + search: String::new(),
335 + commits_only: false,
336 + page: 1,
337 + has_more: false,
338 + from_index: false,
269 339 })
270 340 }
271 341
@@ -296,6 +366,8 @@
296 366 return AnnotationRow {
297 367 target: hex,
298 368 short_target,
369 + namespace: String::new(),
370 + excerpt: String::new(),
299 371 summary: String::new(),
300 372 when: String::new(),
301 373 is_commit: false,
@@ -326,12 +398,177 @@
326 398 AnnotationRow {
327 399 target: hex,
328 400 short_target,
401 + namespace: String::new(),
402 + excerpt: String::new(),
329 403 summary,
330 404 when,
331 405 is_commit: true,
332 406 }
333 407 }
334 408
409 + // ── The index ──
410 +
411 + /// Annotations per page of the feed.
412 + pub const FEED_PAGE_SIZE: i64 = 50;
413 +
414 + /// How many search hits are shown. Search is a way to find one note, not a
415 + /// second feed, so it is one page with no paging and says when it is truncated.
416 + const SEARCH_MAX_HITS: i64 = 100;
417 +
418 + /// How much of a note the feed prints under its target.
419 + const EXCERPT_MAX_CHARS: usize = 240;
420 +
421 + /// What the Notes tab was asked for.
422 + pub struct NotesRequest {
423 + /// Namespace from the URL. `None` means the first one.
424 + pub namespace: Option<String>,
425 + /// Free-text search; empty for the plain feed.
426 + pub search: String,
427 + /// Show only notes on commits.
428 + pub commits_only: bool,
429 + /// 1-based.
430 + pub page: usize,
431 + }
432 +
433 + /// The Notes tab from the Postgres index.
434 + ///
435 + /// `Ok(None)` means this repository has never been indexed, and the caller
436 + /// should walk it instead. That is a real state and not an error: the index is
437 + /// built by a hook and by the two in-process writers, and a repository whose
438 + /// notes predate all three has rows nowhere until `mnw-admin reindex-notes`
439 + /// runs. Reporting it as "no notes" would be a lie a reader cannot see through.
440 + ///
441 + /// `Err(AppError::NotFound)` is a namespace named in the URL that the index does
442 + /// not have, which the handler turns into a 404 rather than falling back to the
443 + /// default namespace.
444 + pub async fn indexed_notes_page(
445 + db: &sqlx::PgPool,
446 + repo_id: crate::db::GitRepoId,
447 + request: &NotesRequest,
448 + ) -> crate::error::Result<Option<NotesPage>> {
449 + use crate::db::git_notes;
450 +
451 + if !git_notes::is_indexed(db, repo_id).await? {
452 + return Ok(None);
453 + }
454 +
455 + let mut names = git_notes::indexed_namespaces(db, repo_id).await?;
456 + // Same order as the repository-backed tab: git's default namespace first,
457 + // then alphabetical.
458 + names.sort_by(|a, b| {
459 + let rank = |n: &String| usize::from(n != notes::DEFAULT_NAMESPACE);
460 + rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
461 + });
462 + let counts: HashMap<String, i64> = git_notes::counts_by_namespace(db, repo_id)
463 + .await?
464 + .into_iter()
465 + .collect();
466 +
467 + let selected = match &request.namespace {
468 + Some(name) => Some(
469 + names
470 + .iter()
471 + .find(|n| *n == name)
472 + .cloned()
473 + .ok_or(crate::error::AppError::NotFound)?,
474 + ),
475 + None => names.first().cloned(),
476 + };
477 +
478 + let namespaces = names
479 + .iter()
480 + .map(|name| NamespaceSummary {
481 + count: counts.get(name).copied().unwrap_or(0).max(0) as usize,
482 + is_current: Some(name) == selected.as_ref(),
483 + name: name.clone(),
484 + })
485 + .collect();
486 +
487 + let page = request.page.max(1);
488 + let (notes, total, has_more) = if request.search.trim().is_empty() {
489 + let total = git_notes::count_notes(db, repo_id, selected.as_deref(), request.commits_only)
490 + .await?
491 + .max(0);
492 + let offset = (page as i64 - 1).saturating_mul(FEED_PAGE_SIZE);
493 + let rows = git_notes::feed(
494 + db,
495 + repo_id,
496 + selected.as_deref(),
497 + request.commits_only,
498 + FEED_PAGE_SIZE,
499 + offset,
500 + )
501 + .await?;
502 + let has_more = offset + (rows.len() as i64) < total;
503 + (rows, total as usize, has_more)
504 + } else {
505 + let rows = git_notes::search(
506 + db,
507 + repo_id,
508 + request.search.trim(),
509 + selected.as_deref(),
510 + request.commits_only,
511 + SEARCH_MAX_HITS,
512 + )
513 + .await?;
514 + // Search reports what it found rather than what exists, so the total is
515 + // the hit count and the cap shows up as the two being equal.
516 + let total = rows.len();
517 + (rows, total, false)
518 + };
519 +
520 + Ok(Some(NotesPage {
521 + namespaces,
522 + selected,
523 + rows: notes.into_iter().map(indexed_row).collect(),
524 + total,
525 + search: request.search.trim().to_string(),
526 + commits_only: request.commits_only,
527 + page,
528 + has_more,
529 + from_index: true,
530 + }))
531 + }
532 +
533 + /// One indexed note as a feed row.
534 + fn indexed_row(note: crate::db::git_notes::IndexedNote) -> AnnotationRow {
535 + let short_target = note
536 + .target_oid
537 + .chars()
538 + .take(SHORT_OID_LEN)
539 + .collect::<String>();
540 + AnnotationRow {
541 + short_target,
542 + namespace: note.namespace,
543 + excerpt: excerpt(&note.content),
544 + summary: note.target_summary,
545 + when: note.updated_at.format("%Y-%m-%d %H:%M UTC").to_string(),
546 + is_commit: note.target_is_commit,
547 + target: note.target_oid,
548 + }
549 + }
550 +
551 + /// The opening of a note, as plain text.
552 + ///
553 + /// Deliberately not rendered markdown: the feed is a list of what has been
554 + /// annotated, and a row that rendered headings and lists would compete with the
555 + /// note itself for being the thing to read. Whitespace is collapsed for the same
556 + /// reason.
557 + fn excerpt(content: &str) -> String {
558 + let mut out = String::with_capacity(EXCERPT_MAX_CHARS);
559 + for word in content.split_whitespace() {
560 + if out.len() + word.len() + 1 > EXCERPT_MAX_CHARS {
561 + out.push('…');
562 + break;
563 + }
564 + if !out.is_empty() {
565 + out.push(' ');
566 + }
567 + out.push_str(word);
568 + }
569 + out
570 + }
571 +
335 572 fn render_attribution(a: Attribution) -> NoteAttribution {
336 573 NoteAttribution {
337 574 short_commit: a.note_commit.to_short_hex(SHORT_OID_LEN),
@@ -492,6 +729,40 @@
492 729 assert!(page.rows.is_empty());
493 730 }
494 731
732 + #[test]
733 + fn an_excerpt_collapses_whitespace_and_stops_on_a_word_boundary() {
734 + assert_eq!(excerpt("one\n\n two\tthree "), "one two three");
735 +
736 + let long = "word ".repeat(200);
737 + let cut = excerpt(&long);
738 + assert!(cut.len() <= EXCERPT_MAX_CHARS + 4, "{}", cut.len());
739 + assert!(cut.ends_with('…'));
740 + // Never mid-word: the excerpt is read, not parsed.
741 + assert!(cut.trim_end_matches('…').ends_with("word"));
742 + }
743 +
744 + #[test]
745 + fn an_indexed_row_prints_the_annotation_time_not_the_commit_time() {
746 + let row = indexed_row(crate::db::git_notes::IndexedNote {
747 + namespace: "commits".into(),
748 + target_oid: TARGET.into(),
749 + blob_oid: "b".repeat(40),
750 + content: "reviewed".into(),
751 + target_is_commit: true,
752 + target_summary: "the commit".into(),
753 + target_time: chrono::DateTime::from_timestamp(1_600_000_000, 0),
754 + updated_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
755 + updated_by: "Fixture".into(),
756 + });
757 +
758 + // The feed orders by when the annotation was written, so that is what
759 + // the row has to show; printing the commit's own date next to rows
760 + // sorted by another one reads as a broken sort.
761 + assert_eq!(row.when, "2023-11-14 22:13 UTC");
762 + assert_eq!(row.short_target, &TARGET[..8]);
763 + assert_eq!(row.excerpt, "reviewed");
764 + }
765 +
495 766 #[test]
496 767 fn an_unannotated_target_and_a_malformed_one_both_render_nothing() {
497 768 let (_tmp, repo) = annotated_repo();
@@ -174,6 +174,17 @@
174 174 /// Notes in the selected namespace, which exceeds `rows.len()` when the feed
175 175 /// was capped.
176 176 pub total: usize,
177 + /// The search these rows answer, echoed back into the box.
178 + pub search: String,
179 + pub commits_only: bool,
180 + /// 1-based page of the feed. Named apart from `page` because Askama would
181 + /// otherwise shadow nothing usefully and the template reads better for it.
182 + pub page_number: usize,
183 + pub has_more: bool,
184 + /// Whether the rows came from the Postgres index. False means the
185 + /// repository was walked, which cannot search or page, so the controls are
186 + /// not offered.
187 + pub from_index: bool,
177 188 pub open_issue_count: i64,
178 189 pub is_owner: bool,
179 190 pub active_tab: &'static str,
@@ -34,6 +34,24 @@
34 34 {% endfor %}
35 35 </nav>
36 36
37 + {# Search, the commits filter and paging are all answered by the Postgres
38 + index. A repository the index has not seen yet is shown from its own
39 + trees, which can do none of the three, so the controls are absent rather
40 + than present and inert. #}
41 + {% if from_index %}
42 + <form class="git-note-search" method="get" action="{% if let Some(ns) = selected %}/git/{{ owner }}/{{ repo_name }}/notes/{{ ns }}{% else %}/git/{{ owner }}/{{ repo_name }}/notes{% endif %}">
43 + <input type="search" name="q" value="{{ search }}" placeholder="Search notes" aria-label="Search notes">
44 + <label><input type="checkbox" name="commits" value="1"{% if commits_only %} checked{% endif %}> Commits only</label>
45 + <button type="submit">Search</button>
46 + {% if !search.is_empty() || commits_only %}
47 + <a href="{% if let Some(ns) = selected %}/git/{{ owner }}/{{ repo_name }}/notes/{{ ns }}{% else %}/git/{{ owner }}/{{ repo_name }}/notes{% endif %}">Clear</a>
48 + {% endif %}
49 + </form>
50 + {% endif %}
51 +
52 + {% if rows.is_empty() %}
53 + {% call ui::empty_state("", "Nothing matches.") %}{% endcall %}
54 + {% else %}
37 55 <ul class="git-commit-list">
38 56 {% for row in rows %}
39 57 <li class="git-commit">
@@ -44,9 +62,14 @@
44 62 <span class="summary git-note-target-kind">Annotated object</span>
45 63 {% endif %}
46 64 </p>
65 + {% if !row.excerpt.is_empty() %}
66 + <p class="git-note-excerpt">{{ row.excerpt }}</p>
67 + {% endif %}
47 68 <div class="git-commit-meta">
48 - {% if row.is_commit %}
69 + {% if !row.when.is_empty() %}
49 70 <span>{{ row.when }}</span>
71 + {% endif %}
72 + {% if row.is_commit %}
50 73 <span class="git-commit-oid">
51 74 <a href="/git/{{ owner }}/{{ repo_name }}/commit/{{ row.target }}#notes">{{ row.short_target }}</a>
52 75 </span>
@@ -57,8 +80,27 @@
57 80 </li>
58 81 {% endfor %}
59 82 </ul>
83 + {% endif %}
60 84
61 - {% if total > rows.len() %}
85 + {% if from_index %}
86 + {% if search.is_empty() %}
87 + <nav class="git-note-paging">
88 + {% if page_number > 1 %}
89 + <a href="?page={{ page_number - 1 }}{% if commits_only %}&amp;commits=1{% endif %}">Newer</a>
90 + {% endif %}
91 + {% if has_more %}
92 + <a href="?page={{ page_number + 1 }}{% if commits_only %}&amp;commits=1{% endif %}">Older</a>
93 + {% endif %}
94 + <span>{{ total }} annotation{% if total != 1 %}s{% endif %}</span>
95 + </nav>
96 + {% else %}
97 + <p class="git-note-feed-cap">{{ total }} match{% if total != 1 %}es{% endif %}.</p>
98 + {% endif %}
99 +
100 + <p class="git-note-feed-link">
101 + <a href="/git/{{ owner }}/{{ repo_name }}/notes.rss{% if let Some(ns) = selected %}?namespace={{ ns }}{% endif %}">Feed</a>
102 + </p>
103 + {% else if total > rows.len() %}
62 104 <p class="git-note-feed-cap">Showing {{ rows.len() }} of {{ total }} annotations.</p>
63 105 {% endif %}
64 106 {% endif %}