Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Repository browsing views (M2).
2
3use maud::{html, Markup, PreEscaped};
4
5use df_store::{EntryKind, Revision, TreeEntry};
6
7use crate::repo_ctx::RepoContext;
8
9/// The repository sub-bar: where you are, whether it is public, the four
10/// sections, and the repository's vital signs.
11///
12/// Rendered full-bleed directly under the masthead by
13/// [`views::page_with_bar`](crate::views::page_with_bar), so it is frame rather
14/// than page content and stays identical across every tab.
15///
16/// The description does *not* appear here. It is repository metadata, not
17/// navigation, and it belongs in the About panel on the repository's own page —
18/// repeating it above every diff is noise on thirty screens to serve one.
19pub fn header(ctx: &RepoContext, active: &str) -> Markup {
20 let base = ctx.base();
21 let nav = &ctx.nav;
22
23 // `None` renders no count at all rather than a zero. "Issues 0" invites a
24 // reader to wonder whether it is broken; a bare label does not.
25 let count = |n: i64| (n > 0).then(|| n.to_string());
26 let tabs: [(&str, &str, String, Option<String>); 4] = [
27 ("code", "Code", base.clone(), None),
28 ("changes", "Changes", format!("{base}/changes"), count(nav.open_changes)),
29 ("issues", "Issues", format!("{base}/issues"), count(nav.open_issues)),
30 ("bookmarks", "Bookmarks", format!("{base}/bookmarks"), count(nav.bookmarks)),
31 ];
32
33 html! {
34 div .subnav {
35 div .subnav-inner {
36 span .subnav-path {
37 a href=(format!("/{}", ctx.owner)) { (ctx.owner) }
38 span .sep aria-hidden="true" { "/" }
39 a href=(base) { (ctx.repo.name) }
40 }
41 span .badge { @if ctx.repo.is_public() { "public" } @else { "private" } }
42 span .vrule aria-hidden="true" {}
43
44 nav .subtabs aria-label="Repository" {
45 @for (key, label, href, n) in &tabs {
46 a href=(href) .active[*key == active]
47 aria-current=[(*key == active).then_some("page")] {
48 (label)
49 @if let Some(n) = n {
50 span .tab-count { (n) }
51 }
52 }
53 }
54 @if ctx.access.can_change_settings() {
55 a href=(format!("{base}/settings")) .active[active == "settings"]
56 aria-current=[(active == "settings").then_some("page")] {
57 "Settings"
58 }
59 }
60 }
61
62 span .spacer {}
63 span .subnav-meta { (nav_meta(nav)) }
64 }
65 }
66 }
67}
68
69/// The right-hand readout on the sub-bar.
70///
71/// Only the facts that are true get a clause: a repository with no conflicts
72/// says nothing about conflicts rather than claiming "0 conflicted", and an
73/// empty repository gets an empty bar instead of three zeroes.
74fn nav_meta(nav: &crate::repo_ctx::RepoNav) -> String {
75 let plural = |n: i64, one: &str, many: &str| if n == 1 { one.to_string() } else { many.to_string() };
76
77 let mut parts = Vec::new();
78 if nav.open_changes > 0 {
79 parts.push(format!("{} open", nav.open_changes));
80 }
81 if nav.conflicted > 0 {
82 parts.push(format!("{} conflicted", nav.conflicted));
83 }
84 if nav.bookmarks > 0 {
85 parts.push(format!(
86 "{} {}",
87 nav.bookmarks,
88 plural(nav.bookmarks, "bookmark", "bookmarks")
89 ));
90 }
91 parts.join(" · ")
92}
93
94#[cfg(test)]
95mod nav_meta_tests {
96 use super::nav_meta;
97 use crate::repo_ctx::RepoNav;
98
99 #[test]
100 fn only_true_facts_get_a_clause() {
101 assert_eq!(
102 nav_meta(&RepoNav { open_changes: 128, conflicted: 3, bookmarks: 9, open_issues: 42 }),
103 "128 open · 3 conflicted · 9 bookmarks"
104 );
105 }
106
107 /// A quiet repository must not advertise three zeroes.
108 #[test]
109 fn a_zero_is_silence_not_a_zero() {
110 assert_eq!(nav_meta(&RepoNav::default()), "");
111 assert_eq!(
112 nav_meta(&RepoNav { open_changes: 1, bookmarks: 1, ..RepoNav::default() }),
113 "1 open · 1 bookmark"
114 );
115 }
116}
117
118/// Clone instructions, shown on an empty repository.
119pub fn empty_repo(https: &str, ssh: &str, default_bookmark: &str) -> Markup {
120 html! {
121 div .panel {
122 h2 { "Push your first change" }
123 p .dim {
124 "This repository is empty. Push to it with " code { "jj" } " or " code { "git" } "."
125 }
126
127 div .proto-toggle {
128 input type="radio" name="clone-protocol" id="proto-https" checked;
129 input type="radio" name="clone-protocol" id="proto-ssh";
130
131 div .proto-tabs role="tablist" aria-label="Protocol" {
132 label .proto-tab for="proto-https" { "HTTPS" }
133 label .proto-tab for="proto-ssh" { "SSH" }
134 }
135
136 div .proto-panel #panel-https {
137 p .label-condensed style="margin-top:14px" { "Clone" }
138 div .clone-box { code { "jj git clone " (https) } }
139
140 p .label-condensed style="margin-top:14px" { "Push an existing repository" }
141 div .clone-box {
142 code {
143 "jj git remote add origin " (https)
144 " && jj git push -b " (default_bookmark)
145 }
146 }
147 p .hint {
148 "Authenticate with your handle and a personal access token from "
149 a href="/settings" { "settings" } "."
150 }
151 }
152
153 div .proto-panel #panel-ssh {
154 p .label-condensed style="margin-top:14px" { "Clone" }
155 div .clone-box { code { "jj git clone " (ssh) } }
156
157 p .label-condensed style="margin-top:14px" { "Push an existing repository" }
158 div .clone-box {
159 code {
160 "jj git remote add origin " (ssh)
161 " && jj git push -b " (default_bookmark)
162 }
163 }
164 p .hint {
165 "Authenticate with an SSH key added in "
166 a href="/settings" { "settings" } "."
167 }
168 }
169 }
170 }
171 }
172}
173
174/// File listing for a tree.
175/// The most recent revision on the branch being browsed.
176///
177/// This is the *directory's* tip, not a per-file blame — see the note on
178/// `tree_listing`.
179pub struct TipCommit {
180 /// The name the commit carries.
181 pub author: String,
182 /// The account that name resolved to, when its email matched one.
183 pub author_handle: Option<String>,
184 pub summary: String,
185 pub when: chrono::DateTime<chrono::Utc>,
186 pub change_id: Option<String>,
187}
188
189/// A monogram stand-in for a user picture.
190///
191/// Dogfood stores no avatars, and fetching one from a third-party service
192/// would leak the viewer's reading habits to that service on every page. Two
193/// letters in a box identify the author well enough for a listing.
194pub fn avatar(name: &str) -> Markup {
195 let initials: String = name.chars().take(2).collect();
196 html! {
197 span .avatar title=(name) aria-hidden="true" { (initials) }
198 }
199}
200
201fn dir_icon() -> Markup {
202 html! {
203 svg .icon-dir width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
204 path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" fill="currentColor" opacity="0.18" {}
205 path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" stroke="currentColor" stroke-width="1.2" {}
206 }
207 }
208}
209
210fn file_icon() -> Markup {
211 html! {
212 svg .icon-file width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
213 path d="M4 1.5h5l3 3v10h-8v-13Z" stroke="currentColor" stroke-width="1.2" {}
214 path d="M9 1.5v3h3" stroke="currentColor" stroke-width="1.2" {}
215 }
216 }
217}
218
219/// The last commit to touch one entry of a directory listing — the
220/// GitHub-style message-and-date column, sourced from `last_commits_in_dir`.
221pub struct EntryHistory {
222 pub summary: String,
223 pub when: chrono::DateTime<chrono::Utc>,
224 /// The jj change id, when the touching commit had one — lets the message
225 /// link to the change page. `None` for a plain-git commit; that history
226 /// still shows the message and date, just not as a link.
227 pub change_id: Option<String>,
228}
229
230/// A bookmark as the sidebar, the switcher and the bookmarks page show it.
231///
232/// Read from the database rather than from git refs: the store knows a name and
233/// an object id, but only the index knows *which change* that object belongs to,
234/// and the change is the thing worth linking to.
235pub struct MarkRow {
236 pub name: String,
237 pub protected: bool,
238 pub updated_at: chrono::DateTime<chrono::Utc>,
239 /// The change at the bookmark's tip, when the indexer knows one. `None` for
240 /// a bookmark pointing at a commit the indexer has not seen — which is a
241 /// real state after a restore, not a bug.
242 pub change_id: Option<String>,
243 pub number: Option<i64>,
244 pub title: Option<String>,
245}
246
247/// The right-hand column on a repository's own page.
248///
249/// Only rendered at the repository root. On a nested directory the reader is
250/// looking at files, and repeating the clone commands beside every folder is
251/// noise.
252pub struct RepoSidebar<'a> {
253 pub https: &'a str,
254 pub ssh: &'a str,
255 pub open_changes: i64,
256 pub conflicted: i64,
257 pub open_issues: i64,
258 /// Distinct commit authors seen by the indexer.
259 pub contributors: i64,
260 pub size_bytes: u64,
261 pub bookmarks: &'a [MarkRow],
262}
263
264/// Everything the directory listing renders.
265pub struct Tree<'a> {
266 /// The bookmark or revision being browsed, as the reader typed it.
267 pub rev_label: &'a str,
268 /// The directory within the tree; empty at the root.
269 pub path: &'a str,
270 pub entries: &'a [TreeEntry],
271 /// The tip of what is being browsed. `None` when the store could not
272 /// produce a log — the listing is the point of the page, so it still
273 /// renders.
274 pub tip: Option<&'a TipCommit>,
275 /// The rendered README and the filename it was actually found under.
276 pub readme: Option<&'a (String, Markup)>,
277 /// Last-commit data per entry name, from one bounded history walk. An
278 /// entry the walk did not reach simply has no history cells.
279 pub history: &'a std::collections::HashMap<String, EntryHistory>,
280 /// Present only at the repository root.
281 pub sidebar: Option<&'a RepoSidebar<'a>>,
282}
283
284/// The directory listing.
285///
286/// One history column, not two: the commit message sits next to the filename
287/// the way it does on GitHub, and takes the place a byte-size column used to
288/// have, in favour of when the file was last touched — which is what a reader
289/// scanning a repo for the first time actually wants to know. Backed by
290/// `last_commits_in_dir`'s single bounded history walk, so an entry the walk
291/// did not reach in 500 commits simply has no history cell rather than a wrong
292/// or misleading one.
293pub fn tree_listing(ctx: &RepoContext, t: Tree<'_>) -> Markup {
294 let Tree { rev_label, path, entries, tip, readme, history, sidebar } = t;
295 let base = ctx.base();
296 let now = chrono::Utc::now();
297
298 let main = html! {
299 div .tree-crumbs {
300 (bookmark_switcher(&base, rev_label, path, sidebar.map(|s| s.bookmarks).unwrap_or(&[])))
301 (breadcrumbs(&base, rev_label, path))
302 }
303
304 div .filelist {
305 @if let Some(t) = tip {
306 div .commit-bar {
307 span .commit-bar-rail aria-hidden="true" {}
308 (avatar(t.author_handle.as_deref().unwrap_or(&t.author)))
309 span .commit-bar-author {
310 (crate::views::person(t.author_handle.as_deref(), Some(&t.author)))
311 }
312 span .commit-bar-msg { (t.summary) }
313 span .spacer {}
314 @if let Some(c) = &t.change_id {
315 a .cid href=(format!("{base}/changes/{c}"))
316 title=(format!("jj change id: {c}")) {
317 (cid_parts(c))
318 }
319 }
320 span .commit-bar-when
321 title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
322 (crate::views::relative_time(t.when, now))
323 }
324 }
325 }
326
327 @if entries.is_empty() {
328 p .dim style="padding:16px" { "This directory is empty." }
329 } @else {
330 table {
331 caption .sr-only { "Files in this directory" }
332 thead {
333 tr {
334 th .filelist-name { "Name" }
335 th .filelist-message { "Last change" }
336 th .filelist-change { "Change" }
337 th .filelist-when { "Updated" }
338 }
339 }
340 tbody {
341 @if !path.is_empty() {
342 tr {
343 td .filelist-name colspan="4" {
344 span .filelist-entry {
345 a .filelist-entry-link.mono
346 href=(parent_link(&base, rev_label, path))
347 aria-label="Parent directory" {
348 (dir_icon())
349 span .filelist-entry-name { ".." }
350 }
351 }
352 }
353 }
354 }
355 @for e in entries {
356 tr {
357 td .filelist-name {
358 span .filelist-entry {
359 a .filelist-entry-link.mono href=(entry_link(&base, rev_label, e))
360 .is-dir[e.kind == EntryKind::Directory] {
361 @if e.kind == EntryKind::Directory { (dir_icon()) } @else { (file_icon()) }
362 span .filelist-entry-name { (e.name) }
363 }
364 @if e.kind == EntryKind::Symlink {
365 span .faint .filelist-note { "symlink" }
366 }
367 }
368 }
369 @match history.get(&e.name) {
370 Some(h) => {
371 td .filelist-message {
372 @match &h.change_id {
373 Some(c) => {
374 a .filelist-message-link
375 href=(format!("{base}/changes/{c}"))
376 title=(h.summary) {
377 (h.summary)
378 }
379 }
380 None => {
381 span .filelist-message-text title=(h.summary) {
382 (h.summary)
383 }
384 }
385 }
386 }
387 td .filelist-change {
388 @if let Some(c) = &h.change_id {
389 a .cid href=(format!("{base}/changes/{c}")) {
390 (cid_parts(c))
391 }
392 }
393 }
394 td .filelist-when
395 title=(h.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
396 (crate::views::relative_time(h.when, now))
397 }
398 }
399 None => {
400 td .filelist-message {}
401 td .filelist-change {}
402 td .filelist-when {}
403 }
404 }
405 }
406 }
407 }
408 }
409 }
410 }
411
412 @if let Some((name, body)) = readme {
413 article .filelist.readme {
414 div .readme-head {
415 span .mono { (name) }
416 }
417 div .readme-body.markdown-body { (body) }
418 }
419 }
420 };
421
422 match sidebar {
423 None => main,
424 Some(s) => html! {
425 div .columns.columns-repo {
426 div .columns-main { (main) }
427 (repo_aside(ctx, s, now))
428 }
429 },
430 }
431}
432
433/// Split a change id into its short prefix and the rest.
434///
435/// Twelve characters is the display length the product settled on; the first
436/// four carry `--identity` because that is the part people actually type and
437/// paste. Selecting across both halves still copies one string.
438pub fn cid_parts(change_id: &str) -> Markup {
439 let shown = &change_id[..12.min(change_id.len())];
440 let split = 4.min(shown.len());
441
442 html! {
443 span .cid-p { (&shown[..split]) }
444 span .cid-r { (&shown[split..]) }
445 }
446}
447
448/// The bookmark switcher.
449///
450/// A `<details>` rather than a scripted menu, so it opens and closes with no
451/// JavaScript at all. Switching keeps the path you are on, which is the whole
452/// point of switching from a directory page.
453fn bookmark_switcher(base: &str, current: &str, path: &str, marks: &[MarkRow]) -> Markup {
454 let target = |name: &str| {
455 if path.is_empty() {
456 format!("{base}/tree/{name}/")
457 } else {
458 format!("{base}/tree/{name}/{path}")
459 }
460 };
461
462 html! {
463 @if marks.len() > 1 {
464 details .switcher {
465 summary .btn.btn-mono {
466 (current)
467 span .faint aria-hidden="true" { " ▾" }
468 }
469 div .switcher-menu {
470 div .label-condensed.switcher-label { "Bookmarks" }
471 @for m in marks {
472 a .switcher-item href=(target(&m.name)) .is-current[m.name == current] {
473 span .mono { (m.name) }
474 @if m.protected {
475 span .bookmark-flag { "protected" }
476 }
477 }
478 }
479 }
480 }
481 } @else {
482 span .btn.btn-mono.is-static { (current) }
483 }
484 }
485}
486
487/// About / Clone / Repo / Bookmarks.
488fn repo_aside(
489 ctx: &RepoContext,
490 s: &RepoSidebar<'_>,
491 now: chrono::DateTime<chrono::Utc>,
492) -> Markup {
493 let base = ctx.base();
494
495 // Only facts that exist get a line. A repository nobody has filed an issue
496 // against should not be told it has zero issues.
497 let stats: Vec<(&str, String, &str)> = [
498 ("Open changes", s.open_changes, "var(--open)"),
499 ("Conflicted", s.conflicted, "var(--conflict)"),
500 ("Open issues", s.open_issues, "var(--text-dim)"),
501 ("Contributors", s.contributors, "var(--text-dim)"),
502 ]
503 .into_iter()
504 .filter(|(_, n, _)| *n > 0)
505 .map(|(k, n, c)| (k, n.to_string(), c))
506 .chain(std::iter::once((
507 "Repository size",
508 human_size(s.size_bytes),
509 "var(--text-faint)",
510 )))
511 .collect();
512
513 html! {
514 aside .columns-aside {
515 @if let Some(d) = &ctx.repo.description {
516 div .aside-block {
517 div .label-condensed { "About" }
518 div .aside-about { (d) }
519 }
520 }
521
522 div .aside-block {
523 div .label-condensed { "Clone" }
524 @for (label, cmd) in [("jj", format!("jj git clone {}", s.https)),
525 ("ssh", format!("jj git clone {}", s.ssh))] {
526 div .aside-clone {
527 span .label-condensed { (label) }
528 code { (cmd) }
529 }
530 }
531 }
532
533 div .aside-block {
534 div .label-condensed { "Repository" }
535 @for (k, v, colour) in &stats {
536 div .dotline {
537 span .dotline-key { (k) }
538 span .dotline-val style=(format!("color:{colour}")) { (v) }
539 }
540 }
541 }
542
543 @if !s.bookmarks.is_empty() {
544 div .aside-block {
545 div .aside-head {
546 div .label-condensed { "Bookmarks" }
547 span .spacer {}
548 a href=(format!("{base}/bookmarks")) style="font-size:var(--text-xs)" {
549 "all →"
550 }
551 }
552 @for m in s.bookmarks.iter().take(6) {
553 div .bookmark-line {
554 span .chip { (m.name) }
555 @if m.protected {
556 span .bookmark-flag { "protected" }
557 }
558 span .spacer {}
559 span .mini-age
560 title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
561 (crate::views::relative_time(m.updated_at, now))
562 }
563 }
564 }
565 }
566 }
567 }
568 }
569}
570
571/// How a markdown file is being shown.
572///
573/// `None` means the file is not markdown, so no toggle appears at all.
574pub enum MarkdownView<'a> {
575 /// Showing the rendered document.
576 Rendered(&'a Markup),
577 /// Showing the highlighted source.
578 Source,
579}
580
581/// Extra data passed to the blob view for the enhanced code view layout.
582pub struct BlobExtras<'a> {
583 /// File tree entries for the sidebar (the directory containing this file).
584 pub sidebar_entries: &'a [TreeEntry],
585 /// The directory shown in the sidebar.
586 pub sidebar_dir: &'a str,
587 /// Symbols extracted from the file for the outline panel.
588 pub symbols: &'a [df_render::symbols::Symbol],
589 /// The last commit that modified this file.
590 pub last_commit: Option<&'a df_store::Revision>,
591 /// The account that commit's author email resolved to, when it matched one.
592 pub last_commit_handle: Option<&'a str>,
593 /// Per-line blame data (when the user toggled blame on).
594 pub blame: Option<&'a [df_store::BlameLine]>,
595 /// Whether blame was requested.
596 pub wants_blame: bool,
597}
598
599impl<'a> Default for BlobExtras<'a> {
600 fn default() -> Self {
601 BlobExtras {
602 sidebar_entries: &[],
603 sidebar_dir: "",
604 symbols: &[],
605 last_commit: None,
606 last_commit_handle: None,
607 blame: None,
608 wants_blame: false,
609 }
610 }
611}
612
613/// A single file.
614///
615/// The rendered/source toggle is two real URLs rather than a scripted control,
616/// so it works with JavaScript disabled like the rest of the product (spec §7)
617/// and each view can be linked to directly.
618///
619/// The toggle says "source" rather than the design's "raw" because this page
620/// already has a Raw button that downloads the file. Two different controls
621/// both labelled "raw" on one page would be a worse outcome than the wording
622/// drifting from the mock.
623pub fn blob_view(
624 ctx: &RepoContext,
625 rev_label: &str,
626 path: &str,
627 body: BlobBody<'_>,
628 markdown: Option<MarkdownView<'_>>,
629 extras: &BlobExtras<'_>,
630) -> Markup {
631 let base = ctx.base();
632 let raw = format!("{base}/raw/{rev_label}/{path}");
633 let here = format!("{base}/blob/{rev_label}/{path}");
634 // Binary and oversized files have no editable representation.
635 let editable = matches!(body, BlobBody::Text { .. });
636 let showing_source = matches!(markdown, Some(MarkdownView::Source));
637
638 html! {
639 // Last commit bar for this file.
640 @if let Some(lc) = extras.last_commit {
641 div .file-commit-bar {
642 (avatar(extras.last_commit_handle.unwrap_or(&lc.author.name)))
643 span .commit-bar-author {
644 (crate::views::person(extras.last_commit_handle, Some(&lc.author.name)))
645 }
646 span .commit-bar-msg { (lc.summary()) }
647 @if let Some(c) = &lc.change_id {
648 span .chip.chip-change title="change id" {
649 (&c[..12.min(c.len())])
650 }
651 }
652 span .faint.tnum
653 title=(lc.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
654 (crate::views::relative_time(lc.author.when, chrono::Utc::now()))
655 }
656 }
657 }
658
659 div .code-view-layout {
660 // ─── Left sidebar: file tree ─────────────────────────────────
661 aside .code-sidebar-left aria-label="File tree" {
662 div .sidebar-header {
663 (dir_icon())
664 span .mono.dim {
665 @if extras.sidebar_dir.is_empty() {
666 "/"
667 } @else {
668 (extras.sidebar_dir)
669 }
670 }
671 }
672 nav .file-tree {
673 @if !extras.sidebar_dir.is_empty() {
674 a .file-tree-item.file-tree-parent href=(
675 parent_link(&base, rev_label, extras.sidebar_dir)
676 ) {
677 (dir_icon()) ".."
678 }
679 }
680 @for e in extras.sidebar_entries {
681 @let is_current = e.path == path;
682 a .file-tree-item
683 .is-dir[e.is_dir()]
684 .is-current[is_current]
685 href=(entry_link(&base, rev_label, e))
686 aria-current=[is_current.then_some("page")] {
687 @if e.is_dir() { (dir_icon()) } @else { (file_icon()) }
688 (e.name)
689 }
690 }
691 }
692 }
693
694 // ─── Center: code panel ──────────────────────────────────────
695 div .code-panel {
696 div .panel {
697 div .row style="margin-bottom:12px" {
698 span .chip { (rev_label) }
699 (breadcrumbs(&base, rev_label, path))
700 span style="margin-left:auto" {}
701
702 // Blame toggle
703 @if editable {
704 @if extras.wants_blame {
705 a .btn.btn-sm href=(here.clone()) { "Hide Blame" }
706 } @else {
707 a .btn.btn-sm href=(format!("{here}?blame=1")) { "Blame" }
708 }
709 }
710
711 @if markdown.is_some() {
712 div .segmented role="group" aria-label="Markdown view" {
713 a .segmented-item .is-on[!showing_source] href=(here.clone()) {
714 "rendered"
715 }
716 a .segmented-item .is-on[showing_source]
717 href=(format!("{here}?view=source")) {
718 "source"
719 }
720 }
721 }
722
723 // Editing needs push access and a text file. Offered only where
724 // it would actually work, rather than shown and then refused.
725 @if ctx.access.can_push() && !ctx.repo.archived && editable {
726 a .btn href=(format!("{base}/edit/{rev_label}/{path}")) { "Edit" }
727 }
728 a .btn href=(raw) { "Raw" }
729 }
730
731 @if let Some(MarkdownView::Rendered(doc)) = &markdown {
732 div .readme-body.markdown-body { (doc) }
733 } @else {
734 @match body {
735 BlobBody::Text { content, lines, highlighted, language, plain_reason } => {
736 div .codeblock {
737 table .mono .codetable {
738 tbody {
739 @for (n, line) in content.lines().enumerate() {
740 tr id=(format!("L{}", n + 1)) {
741 // Blame gutter (when active)
742 @if let Some(blame) = extras.blame {
743 @if let Some(bl) = blame.get(n) {
744 td .blame-cell title=(format!("{}{}", bl.author, bl.summary)) {
745 span .blame-author {
746 (bl.author.chars().take(12).collect::<String>())
747 }
748 }
749 } @else {
750 td .blame-cell {}
751 }
752 }
753 td .faint .lineno {
754 a href=(format!("#L{}", n + 1)) { (n + 1) }
755 }
756 td .codeline {
757 @match highlighted.get(n) {
758 Some(h) => (maud::PreEscaped(h.as_str())),
759 None => (line),
760 }
761 }
762 }
763 }
764 }
765 }
766 }
767 p .hint {
768 (lines) " lines"
769 @if let Some(l) = language { " · " (l) }
770 @if let Some(why) = plain_reason { " · " (why) }
771 }
772 }
773 BlobBody::Binary { size } => {
774 div .empty {
775 h2 { "Binary file" }
776 p { (human_size(size)) " — not shown." }
777 p { a .btn href=(raw) { "Download" } }
778 }
779 }
780 BlobBody::TooLarge { size, limit } => {
781 div .empty {
782 h2 { "File is too large to display" }
783 p { (human_size(size)) ", over the " (human_size(limit)) " render limit." }
784 p { a .btn href=(raw) { "Download" } }
785 }
786 }
787 }
788 }
789 }
790 }
791
792 // ─── Right sidebar: symbols outline ──────────────────────────
793 @if !extras.symbols.is_empty() {
794 aside .code-sidebar-right aria-label="Symbol outline" {
795 div .sidebar-header {
796 span .label-condensed { "Symbols" }
797 }
798 nav .symbol-list {
799 @for sym in extras.symbols {
800 a .symbol-item href=(format!("#L{}", sym.line)) {
801 span .symbol-kind class=(format!("sk-{}", sym.kind.css_class())) {
802 (sym.kind.label())
803 }
804 span .symbol-name { (sym.name) }
805 }
806 }
807 }
808 }
809 }
810 }
811 }
812}
813
814pub enum BlobBody<'a> {
815 Text {
816 content: &'a str,
817 lines: usize,
818 /// Per-line highlighted HTML. Empty when the file rendered plain, and
819 /// indexed by line so a short list degrades line-by-line rather than
820 /// misaligning the whole file.
821 highlighted: &'a [String],
822 language: Option<&'a str>,
823 /// Why highlighting was skipped, when it was.
824 plain_reason: Option<&'a str>,
825 },
826 Binary { size: u64 },
827 TooLarge { size: u64, limit: u64 },
828}
829
830/// Commit log.
831/// The commit log. `handles` maps commit-author email to a Dogfood handle for
832/// the authors that have accounts; everyone else renders as the name the commit
833/// carries.
834pub fn log_view(
835 ctx: &RepoContext,
836 rev_label: &str,
837 revisions: &[Revision],
838 handles: &std::collections::HashMap<String, String>,
839) -> Markup {
840 let base = ctx.base();
841 html! {
842 div .panel {
843 div .row style="margin-bottom:12px" {
844 span .label-condensed { "History" }
845 span .chip { (rev_label) }
846 }
847 @if revisions.is_empty() {
848 p .dim { "No history." }
849 } @else {
850 div .stack style="gap:0" {
851 @for r in revisions {
852 div style="padding:10px 0;border-bottom:1px solid var(--border)" {
853 div .row {
854 // The message leads to the commit's own page —
855 // what it changed is the question a log row
856 // raises, and the tree at that revision is not
857 // an answer to it.
858 a href=(format!("{base}/commit/{}", r.rev)) { (r.summary()) }
859 @if r.conflicted {
860 span .badge.badge-conflict { "conflict" }
861 }
862 }
863 div .row style="margin-top:4px;gap:8px" {
864 // The change id is the identity; the revision is
865 // a point in its history (spec §4).
866 @if let Some(c) = &r.change_id {
867 span .chip.chip-change title="jj change id" {
868 (&c[..12.min(c.len())])
869 }
870 } @else {
871 span .chip title="authored with plain git" { "git" }
872 }
873 a .mono.faint href=(format!("{base}/commit/{}", r.rev))
874 title=(r.rev.as_str()) {
875 (df_store::abbreviate_rev(r.rev.as_str()))
876 }
877 span .faint {
878 (crate::views::person(
879 handles.get(&r.author.email).map(String::as_str),
880 Some(&r.author.name),
881 ))
882 }
883 span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
884 }
885 }
886 }
887 }
888 }
889 }
890 }
891}
892
893// ─── one commit ──────────────────────────────────────────────────────────────
894
895/// Everything the commit page shows about one revision.
896pub struct CommitPage<'a> {
897 pub rev: &'a Revision,
898 /// The account the author's email resolved to, when it matched one.
899 pub author_handle: Option<&'a str>,
900 /// The account the committer's email resolved to. Only rendered when the
901 /// committer differs from the author — on a rebase or an amend they part
902 /// company, and that is exactly when a reader wants to know.
903 pub committer_handle: Option<&'a str>,
904 /// The patch against the first parent. `None` when the diff exceeded the
905 /// store's limits or could not be read.
906 pub diff: Option<&'a df_store::Diff>,
907 /// Fold every file, for skimming the shape of a large commit first.
908 pub collapsed: bool,
909}
910
911/// One commit: what it says, who wrote it, where it sits in history, and what
912/// it changed.
913///
914/// The diff is against the first parent, which is what makes a merge readable
915/// as "what this merge brought in" rather than as a second copy of both sides.
916pub fn commit_view(ctx: &RepoContext, c: CommitPage<'_>) -> Markup {
917 use crate::views::diff as vd;
918
919 let base = ctx.base();
920 let rev = c.rev.rev.as_str();
921 let blob_base = format!("{base}/blob/{rev}");
922 let now = chrono::Utc::now();
923 // The commit's own hash, not a bookmark name: a page about one commit must
924 // keep pointing at that commit after the bookmark moves.
925 let toggle = if c.collapsed {
926 format!("{base}/commit/{rev}")
927 } else {
928 format!("{base}/commit/{rev}?collapse=1")
929 };
930
931 // A different committer is worth a line; the usual case, where they are the
932 // same person, is not.
933 let amended = c.rev.committer.email != c.rev.author.email;
934
935 html! {
936 div .panel .commit-meta {
937 div .commit-msg {
938 h1 { (c.rev.summary()) }
939 @if !c.rev.body().is_empty() {
940 pre .commit-body { (c.rev.body()) }
941 }
942 }
943
944 div .commit-byline {
945 (avatar(c.author_handle.unwrap_or(&c.rev.author.name)))
946 span {
947 (crate::views::person(c.author_handle, Some(&c.rev.author.name)))
948 " authored "
949 span .faint title=(c.rev.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
950 (crate::views::relative_time(c.rev.author.when, now))
951 }
952 }
953 @if amended {
954 span .faint {
955 "· committed by "
956 (crate::views::person(c.committer_handle, Some(&c.rev.committer.name)))
957 " "
958 span title=(c.rev.committer.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
959 (crate::views::relative_time(c.rev.committer.when, now))
960 }
961 }
962 }
963 @if c.rev.conflicted {
964 span .badge.badge-conflict { "conflict" }
965 }
966 }
967
968 div .commit-ids {
969 // The change id is the identity; the revision is a point in its
970 // history (spec §4). Both are shown, and the identity links.
971 @if let Some(id) = &c.rev.change_id {
972 a .cid href=(format!("{base}/changes/{id}"))
973 title=(format!("jj change id: {id}")) { (cid_parts(id)) }
974 } @else {
975 span .chip title="authored with plain git" { "git" }
976 }
977 span .mono.faint title=(rev) { (df_store::abbreviate_rev(rev)) }
978
979 span .spacer {}
980
981 @for p in &c.rev.parents {
982 a .mono.commit-parent href=(format!("{base}/commit/{p}"))
983 title=(format!("parent {p}")) {
984 "parent " (df_store::abbreviate_rev(p.as_str()))
985 }
986 }
987 a href=(format!("{base}/tree/{rev}/")) { "Browse files" }
988 }
989 }
990
991 @match c.diff {
992 None => div .panel { p .dim style="margin:0" {
993 "This commit's diff could not be rendered. Fetch the revision with "
994 code { "jj" } " to read it in full."
995 } },
996 Some(d) => {
997 div .diffbar {
998 (vd::stat_summary(d))
999 span .spacer {}
1000 @if !d.files.is_empty() {
1001 a .diffbar-link href=(toggle) {
1002 (if c.collapsed { "Expand all" } else { "Collapse all" })
1003 }
1004 }
1005 }
1006 // The tree stands in for the flat file index here: it answers
1007 // the same "which files" question and answers it better, and
1008 // stacking both above the diff would make the reader scroll
1009 // past the same thirty paths twice.
1010 div .difflayout {
1011 (vd::tree(d))
1012 div .diffmain {
1013 (vd::files(&vd::DiffView {
1014 collapsed: c.collapsed,
1015 // A commit is a revision the browse routes can
1016 // serve, so every file header reaches the whole
1017 // file at this point in history — the step a hunk
1018 // always raises.
1019 blob_base: Some(&blob_base),
1020 ..vd::DiffView::new(d)
1021 }))
1022 }
1023 }
1024 }
1025 }
1026 }
1027}
1028
1029/// Bookmark list.
1030/// The bookmarks page.
1031///
1032/// Four columns, and the second one is the argument: a bookmark *points at* a
1033/// change. The name in column one can move to any other row tomorrow; the id in
1034/// column two is what the review, the approvals and the permalinks are attached
1035/// to. The page exists to make that asymmetry visible.
1036pub fn bookmarks_view(ctx: &RepoContext, marks: &[MarkRow]) -> Markup {
1037 let base = ctx.base();
1038 let now = chrono::Utc::now();
1039
1040 html! {
1041 div .page-head {
1042 div {
1043 h1 { "Bookmarks" }
1044 p .dim.section-note {
1045 "Bookmarks are movable pointers, not identities. Reviews attach to changes."
1046 }
1047 }
1048 }
1049
1050 @if marks.is_empty() {
1051 div .empty {
1052 h2 { "No bookmarks yet" }
1053 p { "Push one with " code { "jj git push --bookmark <name>" } "." }
1054 }
1055 } @else {
1056 div .filelist {
1057 table .bookmark-table {
1058 caption .sr-only { "Bookmarks in this repository" }
1059 thead {
1060 tr {
1061 th { "Bookmark" }
1062 th { "Points at" }
1063 th { "Title" }
1064 th { "Updated" }
1065 }
1066 }
1067 tbody {
1068 @for m in marks {
1069 tr {
1070 td .bookmark-name {
1071 a .mono href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
1072 @if m.name == ctx.repo.default_bookmark {
1073 span .bookmark-flag { "default" }
1074 }
1075 @if m.protected {
1076 span .bookmark-flag { "protected" }
1077 }
1078 }
1079 td .bookmark-points {
1080 @match (&m.change_id, m.number) {
1081 (Some(c), Some(n)) => {
1082 a .cid href=(format!("{base}/changes/{n}"))
1083 title=(format!("jj change id: {c}")) {
1084 (cid_parts(c))
1085 }
1086 }
1087 // A bookmark the indexer has not caught
1088 // up with. Saying so beats an empty cell
1089 // that reads as a rendering bug.
1090 _ => span .faint.mono { "not indexed" },
1091 }
1092 }
1093 td .bookmark-title {
1094 @if let Some(t) = &m.title { (t) }
1095 }
1096 td .bookmark-when
1097 title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
1098 (crate::views::relative_time(m.updated_at, now))
1099 }
1100 }
1101 }
1102 }
1103 }
1104 }
1105 }
1106 }
1107}
1108
1109/// New-repository form.
1110pub fn new_repo_form(csrf: &str, error: Option<&str>, owners: &[String]) -> Markup {
1111 html! {
1112 div .panel {
1113 h1 { "New repository" }
1114 @if let Some(e) = error {
1115 div .banner.banner-error role="alert" { (e) }
1116 }
1117 form method="post" action="/repos" {
1118 input type="hidden" name="_csrf" value=(csrf);
1119
1120 div .field {
1121 label for="owner" { "Owner" }
1122 select id="owner" name="owner"
1123 style="padding:7px 10px;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius);color:var(--text);font:inherit" {
1124 @for o in owners {
1125 option value=(o) { (o) }
1126 }
1127 }
1128 }
1129
1130 div .field {
1131 label for="name" { "Repository name" }
1132 input type="text" id="name" name="name" required
1133 maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._\\-]*"
1134 autocomplete="off" autofocus;
1135 p .hint { "Letters, digits, dots, hyphens and underscores." }
1136 }
1137
1138 div .field {
1139 label for="description" { "Description (optional)" }
1140 input type="text" id="description" name="description" maxlength="500";
1141 }
1142
1143 div .field {
1144 label for="default_bookmark" { "Default bookmark" }
1145 input type="text" id="default_bookmark" name="default_bookmark"
1146 value="main" maxlength="100" required;
1147 }
1148
1149 div .field {
1150 label {
1151 input type="checkbox" name="private" value="1" checked
1152 style="width:auto;margin-right:8px";
1153 "Private"
1154 }
1155 }
1156
1157 button .btn.btn-primary type="submit" { "Create repository" }
1158 }
1159 }
1160 }
1161}
1162
1163// ─── helpers ─────────────────────────────────────────────────────────────────
1164
1165fn entry_link(base: &str, rev: &str, e: &TreeEntry) -> String {
1166 let kind = if e.is_dir() { "tree" } else { "blob" };
1167 format!("{base}/{kind}/{rev}/{}", e.path)
1168}
1169
1170fn parent_link(base: &str, rev: &str, path: &str) -> String {
1171 let parent = match path.rsplit_once('/') {
1172 Some((p, _)) => p,
1173 None => "",
1174 };
1175 format!("{base}/tree/{rev}/{parent}")
1176}
1177
1178pub fn breadcrumbs(base: &str, rev: &str, path: &str) -> Markup {
1179 html! {
1180 span {
1181 a href=(format!("{base}/tree/{rev}/")) { "root" }
1182 @for (label, acc) in df_store::path::breadcrumbs(path) {
1183 span .faint { " / " }
1184 a href=(format!("{base}/tree/{rev}/{acc}")) { (label) }
1185 }
1186 }
1187 }
1188}
1189
1190/// Human-readable byte count.
1191pub fn human_size(n: u64) -> String {
1192 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1193 let mut v = n as f64;
1194 let mut i = 0;
1195 while v >= 1024.0 && i < UNITS.len() - 1 {
1196 v /= 1024.0;
1197 i += 1;
1198 }
1199 if i == 0 {
1200 format!("{n} B")
1201 } else {
1202 format!("{v:.1} {}", UNITS[i])
1203 }
1204}
1205
1206/// Markdown that has already been sanitised by `df-render`.
1207pub fn rendered_markdown(html_str: &str) -> Markup {
1208 PreEscaped(html_str.to_string())
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn human_size_is_readable() {
1217 assert_eq!(human_size(0), "0 B");
1218 assert_eq!(human_size(512), "512 B");
1219 assert_eq!(human_size(1024), "1.0 KB");
1220 assert_eq!(human_size(1536), "1.5 KB");
1221 assert_eq!(human_size(1024 * 1024), "1.0 MB");
1222 }
1223
1224 #[test]
1225 fn parent_link_walks_up_one_level() {
1226 assert_eq!(parent_link("/o/r", "main", "a/b/c"), "/o/r/tree/main/a/b");
1227 assert_eq!(parent_link("/o/r", "main", "a"), "/o/r/tree/main/");
1228 }
1229
1230 // ─── the commit page ─────────────────────────────────────────────────────
1231
1232 fn ctx() -> RepoContext {
1233 RepoContext {
1234 repo: df_db::models::Repo {
1235 id: uuid::Uuid::nil(),
1236 owner_kind: df_db::models::OwnerKind::User,
1237 owner_user_id: None,
1238 owner_org_id: None,
1239 name: "r".into(),
1240 description: None,
1241 visibility: df_db::models::Visibility::Public,
1242 default_bookmark: "main".into(),
1243 fork_of_repo_id: None,
1244 size_bytes: 0,
1245 pushed_at: None,
1246 archived: false,
1247 created_at: chrono::Utc::now(),
1248 },
1249 owner: "o".into(),
1250 access: df_auth::RepoAccess::DENIED,
1251 nav: crate::repo_ctx::RepoNav::default(),
1252 }
1253 }
1254
1255 fn sig(name: &str) -> df_store::Signature {
1256 df_store::Signature {
1257 name: name.into(),
1258 email: format!("{name}@example.test"),
1259 when: chrono::Utc::now(),
1260 }
1261 }
1262
1263 fn revision() -> Revision {
1264 Revision {
1265 rev: df_store::RevId::from_stored(
1266 "0123456789abcdef0123456789abcdef01234567".to_string(),
1267 ),
1268 change_id: Some("kksontuqryot".into()),
1269 parents: vec![df_store::RevId::from_stored(
1270 "fedcba9876543210fedcba9876543210fedcba98".to_string(),
1271 )],
1272 author: sig("alice"),
1273 committer: sig("alice"),
1274 message: "fix the thing\n\nA longer explanation.\n".into(),
1275 conflicted: false,
1276 conflict_sides: Vec::new(),
1277 conflict_bases: Vec::new(),
1278 }
1279 }
1280
1281 fn page(rev: &Revision, diff: Option<&df_store::Diff>) -> String {
1282 commit_view(
1283 &ctx(),
1284 CommitPage {
1285 rev,
1286 author_handle: Some("alice"),
1287 committer_handle: Some("alice"),
1288 diff,
1289 collapsed: false,
1290 },
1291 )
1292 .into_string()
1293 }
1294
1295 /// The three things a commit page is for: what it says, which commit it is,
1296 /// and what it changed.
1297 #[test]
1298 fn a_commit_shows_its_message_its_ids_and_its_patch() {
1299 let r = revision();
1300 let d = crate::views::diff::tests::fixture(6);
1301 let html = page(&r, Some(&d));
1302
1303 assert!(html.contains("fix the thing"));
1304 assert!(html.contains("A longer explanation."));
1305 // Abbreviation goes through the store (spec §3 rule 2) — never a slice.
1306 assert!(html.contains("0123456789ab"), "{html:.800}");
1307 assert!(!html.contains("0123456789abcdef0123456789abcdef01234567<"));
1308 // The change id is the identity, and it links to the change.
1309 assert!(html.contains("href=\"/o/r/changes/kksontuqryot\""));
1310 assert!(html.contains("difftable"));
1311 }
1312
1313 /// The parent is the other half of "what changed": the diff is *against* it,
1314 /// so walking back one commit has to be one click.
1315 #[test]
1316 fn parents_link_to_their_own_commit_pages() {
1317 let r = revision();
1318 let html = page(&r, None);
1319 assert!(
1320 html.contains(
1321 "href=\"/o/r/commit/fedcba9876543210fedcba9876543210fedcba98\""
1322 ),
1323 "{html:.800}"
1324 );
1325 }
1326
1327 /// A commit whose patch could not be rendered still has a message, an
1328 /// author and parents worth reading.
1329 #[test]
1330 fn a_commit_with_no_renderable_diff_still_renders() {
1331 let html = page(&revision(), None);
1332 assert!(html.contains("fix the thing"));
1333 assert!(html.contains("could not be rendered"));
1334 assert!(!html.contains("difftable"));
1335 }
1336
1337 /// The collapse link is a URL, so a folded commit can be pasted at
1338 /// somebody — and it points back at the commit's own hash rather than at
1339 /// whatever bookmark was typed to reach it.
1340 #[test]
1341 fn the_collapse_toggle_is_a_permalink() {
1342 let r = revision();
1343 let d = crate::views::diff::tests::fixture(6);
1344 let expanded = page(&r, Some(&d));
1345 assert!(expanded.contains(&format!("/o/r/commit/{}?collapse=1", r.rev)));
1346
1347 let folded = commit_view(
1348 &ctx(),
1349 CommitPage {
1350 rev: &r,
1351 author_handle: None,
1352 committer_handle: None,
1353 diff: Some(&d),
1354 collapsed: true,
1355 },
1356 )
1357 .into_string();
1358 assert!(folded.contains("Expand all"));
1359 assert!(!folded.contains("collapse=1"));
1360 }
1361
1362 /// An amend or a rebase parts the author from the committer, and that is
1363 /// exactly when the second name is worth the line it costs.
1364 #[test]
1365 fn a_committer_is_only_named_when_they_differ_from_the_author() {
1366 assert!(!page(&revision(), None).contains("committed by"));
1367
1368 let mut r = revision();
1369 r.committer = sig("bob");
1370 assert!(page(&r, None).contains("committed by"));
1371 }
1372}

1372 lines · Rust