max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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, ¬es_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: ¬es::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: ¬es::NotesCache, | |
| 271 | + | repo_key: &str, | |
| 272 | + | ns: ¬es::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, ¬es::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, ¬es::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, ¬es::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, ¬es::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, ¬es::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 %} |