Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Repository browsing views (M2).
Matt W2
Matt W3use maud::{html, Markup, PreEscaped};
Matt W4
Matt W5use df_store::{EntryKind, Revision, TreeEntry};
Matt W6
Matt W7use crate::repo_ctx::RepoContext;
Matt W8
Matt W9/// The repository sub-bar: where you are, whether it is public, the four
Matt W10/// sections, and the repository's vital signs.
Matt W11///
Matt W12/// Rendered full-bleed directly under the masthead by
Matt W13/// [`views::page_with_bar`](crate::views::page_with_bar), so it is frame rather
Matt W14/// than page content and stays identical across every tab.
Matt W15///
Matt W16/// The description does *not* appear here. It is repository metadata, not
Matt W17/// navigation, and it belongs in the About panel on the repository's own page —
Matt W18/// repeating it above every diff is noise on thirty screens to serve one.
Matt W19pub fn header(ctx: &RepoContext, active: &str) -> Markup {
Matt W20 let base = ctx.base();
Matt W21 let nav = &ctx.nav;
Matt W22
Matt W23 // `None` renders no count at all rather than a zero. "Issues 0" invites a
Matt W24 // reader to wonder whether it is broken; a bare label does not.
Matt W25 let count = |n: i64| (n > 0).then(|| n.to_string());
Matt W26 let tabs: [(&str, &str, String, Option<String>); 4] = [
Matt W27 ("code", "Code", base.clone(), None),
Matt W28 ("changes", "Changes", format!("{base}/changes"), count(nav.open_changes)),
Matt W29 ("issues", "Issues", format!("{base}/issues"), count(nav.open_issues)),
Matt W30 ("bookmarks", "Bookmarks", format!("{base}/bookmarks"), count(nav.bookmarks)),
Matt W31 ];
Matt W32
Matt W33 html! {
Matt W34 div .subnav {
Matt W35 div .subnav-inner {
Matt W36 span .subnav-path {
Matt W37 a href=(format!("/{}", ctx.owner)) { (ctx.owner) }
Matt W38 span .sep aria-hidden="true" { "/" }
Matt W39 a href=(base) { (ctx.repo.name) }
Matt W40 }
Matt W41 span .badge { @if ctx.repo.is_public() { "public" } @else { "private" } }
Matt W42 span .vrule aria-hidden="true" {}
Matt W43
Matt W44 nav .subtabs aria-label="Repository" {
Matt W45 @for (key, label, href, n) in &tabs {
Matt W46 a href=(href) .active[*key == active]
Matt W47 aria-current=[(*key == active).then_some("page")] {
Matt W48 (label)
Matt W49 @if let Some(n) = n {
Matt W50 span .tab-count { (n) }
Matt W51 }
Matt W52 }
Matt W53 }
Matt W54 @if ctx.access.can_change_settings() {
Matt W55 a href=(format!("{base}/settings")) .active[active == "settings"]
Matt W56 aria-current=[(active == "settings").then_some("page")] {
Matt W57 "Settings"
Matt W58 }
Matt W59 }
Matt W60 }
Matt W61
Matt W62 span .spacer {}
Matt W63 span .subnav-meta { (nav_meta(nav)) }
Matt W64 }
Matt W65 }
Matt W66 }
Matt W67}
Matt W68
Matt W69/// The right-hand readout on the sub-bar.
Matt W70///
Matt W71/// Only the facts that are true get a clause: a repository with no conflicts
Matt W72/// says nothing about conflicts rather than claiming "0 conflicted", and an
Matt W73/// empty repository gets an empty bar instead of three zeroes.
Matt W74fn nav_meta(nav: &crate::repo_ctx::RepoNav) -> String {
Matt W75 let plural = |n: i64, one: &str, many: &str| if n == 1 { one.to_string() } else { many.to_string() };
Matt W76
Matt W77 let mut parts = Vec::new();
Matt W78 if nav.open_changes > 0 {
Matt W79 parts.push(format!("{} open", nav.open_changes));
Matt W80 }
Matt W81 if nav.conflicted > 0 {
Matt W82 parts.push(format!("{} conflicted", nav.conflicted));
Matt W83 }
Matt W84 if nav.bookmarks > 0 {
Matt W85 parts.push(format!(
Matt W86 "{} {}",
Matt W87 nav.bookmarks,
Matt W88 plural(nav.bookmarks, "bookmark", "bookmarks")
Matt W89 ));
Matt W90 }
Matt W91 parts.join(" · ")
Matt W92}
Matt W93
Matt W94#[cfg(test)]
Matt W95mod nav_meta_tests {
Matt W96 use super::nav_meta;
Matt W97 use crate::repo_ctx::RepoNav;
Matt W98
Matt W99 #[test]
Matt W100 fn only_true_facts_get_a_clause() {
Matt W101 assert_eq!(
Matt W102 nav_meta(&RepoNav { open_changes: 128, conflicted: 3, bookmarks: 9, open_issues: 42 }),
Matt W103 "128 open · 3 conflicted · 9 bookmarks"
Matt W104 );
Matt W105 }
Matt W106
Matt W107 /// A quiet repository must not advertise three zeroes.
Matt W108 #[test]
Matt W109 fn a_zero_is_silence_not_a_zero() {
Matt W110 assert_eq!(nav_meta(&RepoNav::default()), "");
Matt W111 assert_eq!(
Matt W112 nav_meta(&RepoNav { open_changes: 1, bookmarks: 1, ..RepoNav::default() }),
Matt W113 "1 open · 1 bookmark"
Matt W114 );
Matt W115 }
Matt W116}
Matt W117
Matt W118/// Clone instructions, shown on an empty repository.
Matt W119pub fn empty_repo(https: &str, ssh: &str, default_bookmark: &str) -> Markup {
Matt W120 html! {
Matt W121 div .panel {
Matt W122 h2 { "Push your first change" }
Matt W123 p .dim {
Matt W124 "This repository is empty. Push to it with " code { "jj" } " or " code { "git" } "."
Matt W125 }
Matt W126
Matt W127 div .proto-toggle {
Matt W128 input type="radio" name="clone-protocol" id="proto-https" checked;
Matt W129 input type="radio" name="clone-protocol" id="proto-ssh";
Matt W130
Matt W131 div .proto-tabs role="tablist" aria-label="Protocol" {
Matt W132 label .proto-tab for="proto-https" { "HTTPS" }
Matt W133 label .proto-tab for="proto-ssh" { "SSH" }
Matt W134 }
Matt W135
Matt W136 div .proto-panel #panel-https {
Matt W137 p .label-condensed style="margin-top:14px" { "Clone" }
Matt W138 div .clone-box { code { "jj git clone " (https) } }
Matt W139
Matt W140 p .label-condensed style="margin-top:14px" { "Push an existing repository" }
Matt W141 div .clone-box {
Matt W142 code {
Matt W143 "jj git remote add origin " (https)
Matt W144 " && jj git push -b " (default_bookmark)
Matt W145 }
Matt W146 }
Matt W147 p .hint {
Matt W148 "Authenticate with your handle and a personal access token from "
Matt W149 a href="/settings" { "settings" } "."
Matt W150 }
Matt W151 }
Matt W152
Matt W153 div .proto-panel #panel-ssh {
Matt W154 p .label-condensed style="margin-top:14px" { "Clone" }
Matt W155 div .clone-box { code { "jj git clone " (ssh) } }
Matt W156
Matt W157 p .label-condensed style="margin-top:14px" { "Push an existing repository" }
Matt W158 div .clone-box {
Matt W159 code {
Matt W160 "jj git remote add origin " (ssh)
Matt W161 " && jj git push -b " (default_bookmark)
Matt W162 }
Matt W163 }
Matt W164 p .hint {
Matt W165 "Authenticate with an SSH key added in "
Matt W166 a href="/settings" { "settings" } "."
Matt W167 }
Matt W168 }
Matt W169 }
Matt W170 }
Matt W171 }
Matt W172}
Matt W173
Matt W174/// File listing for a tree.
Matt W175/// The most recent revision on the branch being browsed.
Matt W176///
Matt W177/// This is the *directory's* tip, not a per-file blame — see the note on
Matt W178/// `tree_listing`.
Matt W179pub struct TipCommit {
Matt W180 /// The name the commit carries.
Matt W181 pub author: String,
Matt W182 /// The account that name resolved to, when its email matched one.
Matt W183 pub author_handle: Option<String>,
Matt W184 pub summary: String,
Matt W185 pub when: chrono::DateTime<chrono::Utc>,
Matt W186 pub change_id: Option<String>,
Matt W187}
Matt W188
Matt W189/// A monogram stand-in for a user picture.
Matt W190///
Matt W191/// Dogfood stores no avatars, and fetching one from a third-party service
Matt W192/// would leak the viewer's reading habits to that service on every page. Two
Matt W193/// letters in a box identify the author well enough for a listing.
Matt W194pub fn avatar(name: &str) -> Markup {
Matt W195 let initials: String = name.chars().take(2).collect();
Matt W196 html! {
Matt W197 span .avatar title=(name) aria-hidden="true" { (initials) }
Matt W198 }
Matt W199}
Matt W200
Matt W201fn dir_icon() -> Markup {
Matt W202 html! {
Matt W203 svg .icon-dir width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
Matt W204 path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" fill="currentColor" opacity="0.18" {}
Matt W205 path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" stroke="currentColor" stroke-width="1.2" {}
Matt W206 }
Matt W207 }
Matt W208}
Matt W209
Matt W210fn file_icon() -> Markup {
Matt W211 html! {
Matt W212 svg .icon-file width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {
Matt W213 path d="M4 1.5h5l3 3v10h-8v-13Z" stroke="currentColor" stroke-width="1.2" {}
Matt W214 path d="M9 1.5v3h3" stroke="currentColor" stroke-width="1.2" {}
Matt W215 }
Matt W216 }
Matt W217}
Matt W218
Matt W219/// The last commit to touch one entry of a directory listing — the
Matt W220/// GitHub-style message-and-date column, sourced from `last_commits_in_dir`.
Matt W221pub struct EntryHistory {
Matt W222 pub summary: String,
Matt W223 pub when: chrono::DateTime<chrono::Utc>,
Matt W224 /// The jj change id, when the touching commit had one — lets the message
Matt W225 /// link to the change page. `None` for a plain-git commit; that history
Matt W226 /// still shows the message and date, just not as a link.
Matt W227 pub change_id: Option<String>,
Matt W228}
Matt W229
Matt W230/// A bookmark as the sidebar, the switcher and the bookmarks page show it.
Matt W231///
Matt W232/// Read from the database rather than from git refs: the store knows a name and
Matt W233/// an object id, but only the index knows *which change* that object belongs to,
Matt W234/// and the change is the thing worth linking to.
Matt W235pub struct MarkRow {
Matt W236 pub name: String,
Matt W237 pub protected: bool,
Matt W238 pub updated_at: chrono::DateTime<chrono::Utc>,
Matt W239 /// The change at the bookmark's tip, when the indexer knows one. `None` for
Matt W240 /// a bookmark pointing at a commit the indexer has not seen — which is a
Matt W241 /// real state after a restore, not a bug.
Matt W242 pub change_id: Option<String>,
Matt W243 pub number: Option<i64>,
Matt W244 pub title: Option<String>,
Matt W245}
Matt W246
Matt W247/// The right-hand column on a repository's own page.
Matt W248///
Matt W249/// Only rendered at the repository root. On a nested directory the reader is
Matt W250/// looking at files, and repeating the clone commands beside every folder is
Matt W251/// noise.
Matt W252pub struct RepoSidebar<'a> {
Matt W253 pub https: &'a str,
Matt W254 pub ssh: &'a str,
Matt W255 pub open_changes: i64,
Matt W256 pub conflicted: i64,
Matt W257 pub open_issues: i64,
Matt W258 /// Distinct commit authors seen by the indexer.
Matt W259 pub contributors: i64,
Matt W260 pub size_bytes: u64,
Matt W261 pub bookmarks: &'a [MarkRow],
Matt W262}
Matt W263
Matt W264/// Everything the directory listing renders.
Matt W265pub struct Tree<'a> {
Matt W266 /// The bookmark or revision being browsed, as the reader typed it.
Matt W267 pub rev_label: &'a str,
Matt W268 /// The directory within the tree; empty at the root.
Matt W269 pub path: &'a str,
Matt W270 pub entries: &'a [TreeEntry],
Matt W271 /// The tip of what is being browsed. `None` when the store could not
Matt W272 /// produce a log — the listing is the point of the page, so it still
Matt W273 /// renders.
Matt W274 pub tip: Option<&'a TipCommit>,
Matt W275 /// The rendered README and the filename it was actually found under.
Matt W276 pub readme: Option<&'a (String, Markup)>,
Matt W277 /// Last-commit data per entry name, from one bounded history walk. An
Matt W278 /// entry the walk did not reach simply has no history cells.
Matt W279 pub history: &'a std::collections::HashMap<String, EntryHistory>,
Matt W280 /// Present only at the repository root.
Matt W281 pub sidebar: Option<&'a RepoSidebar<'a>>,
Matt W282}
Matt W283
Matt W284/// The directory listing.
Matt W285///
Matt W286/// One history column, not two: the commit message sits next to the filename
Matt W287/// the way it does on GitHub, and takes the place a byte-size column used to
Matt W288/// have, in favour of when the file was last touched — which is what a reader
Matt W289/// scanning a repo for the first time actually wants to know. Backed by
Matt W290/// `last_commits_in_dir`'s single bounded history walk, so an entry the walk
Matt W291/// did not reach in 500 commits simply has no history cell rather than a wrong
Matt W292/// or misleading one.
Matt W293pub fn tree_listing(ctx: &RepoContext, t: Tree<'_>) -> Markup {
Matt W294 let Tree { rev_label, path, entries, tip, readme, history, sidebar } = t;
Matt W295 let base = ctx.base();
Matt W296 let now = chrono::Utc::now();
Matt W297
Matt W298 let main = html! {
Matt W299 div .tree-crumbs {
Matt W300 (bookmark_switcher(&base, rev_label, path, sidebar.map(|s| s.bookmarks).unwrap_or(&[])))
Matt W301 (breadcrumbs(&base, rev_label, path))
Matt W302 }
Matt W303
Matt W304 div .filelist {
Matt W305 @if let Some(t) = tip {
Matt W306 div .commit-bar {
Matt W307 span .commit-bar-rail aria-hidden="true" {}
Matt W308 (avatar(t.author_handle.as_deref().unwrap_or(&t.author)))
Matt W309 span .commit-bar-author {
Matt W310 (crate::views::person(t.author_handle.as_deref(), Some(&t.author)))
Matt W311 }
Matt W312 span .commit-bar-msg { (t.summary) }
Matt W313 span .spacer {}
Matt W314 @if let Some(c) = &t.change_id {
Matt W315 a .cid href=(format!("{base}/changes/{c}"))
Matt W316 title=(format!("jj change id: {c}")) {
Matt W317 (cid_parts(c))
Matt W318 }
Matt W319 }
Matt W320 span .commit-bar-when
Matt W321 title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W322 (crate::views::relative_time(t.when, now))
Matt W323 }
Matt W324 }
Matt W325 }
Matt W326
Matt W327 @if entries.is_empty() {
Matt W328 p .dim style="padding:16px" { "This directory is empty." }
Matt W329 } @else {
Matt W330 table {
Matt W331 caption .sr-only { "Files in this directory" }
Matt W332 thead {
Matt W333 tr {
Matt W334 th .filelist-name { "Name" }
Matt W335 th .filelist-message { "Last change" }
Matt W336 th .filelist-change { "Change" }
Matt W337 th .filelist-when { "Updated" }
Matt W338 }
Matt W339 }
Matt W340 tbody {
Matt W341 @if !path.is_empty() {
Matt W342 tr {
Matt W343 td .filelist-name colspan="4" {
Matt W344 span .filelist-entry {
Matt W345 a .filelist-entry-link.mono
Matt W346 href=(parent_link(&base, rev_label, path))
Matt W347 aria-label="Parent directory" {
Matt W348 (dir_icon())
Matt W349 span .filelist-entry-name { ".." }
Matt W350 }
Matt W351 }
Matt W352 }
Matt W353 }
Matt W354 }
Matt W355 @for e in entries {
Matt W356 tr {
Matt W357 td .filelist-name {
Matt W358 span .filelist-entry {
Matt W359 a .filelist-entry-link.mono href=(entry_link(&base, rev_label, e))
Matt W360 .is-dir[e.kind == EntryKind::Directory] {
Matt W361 @if e.kind == EntryKind::Directory { (dir_icon()) } @else { (file_icon()) }
Matt W362 span .filelist-entry-name { (e.name) }
Matt W363 }
Matt W364 @if e.kind == EntryKind::Symlink {
Matt W365 span .faint .filelist-note { "symlink" }
Matt W366 }
Matt W367 }
Matt W368 }
Matt W369 @match history.get(&e.name) {
Matt W370 Some(h) => {
Matt W371 td .filelist-message {
Matt W372 @match &h.change_id {
Matt W373 Some(c) => {
Matt W374 a .filelist-message-link
Matt W375 href=(format!("{base}/changes/{c}"))
Matt W376 title=(h.summary) {
Matt W377 (h.summary)
Matt W378 }
Matt W379 }
Matt W380 None => {
Matt W381 span .filelist-message-text title=(h.summary) {
Matt W382 (h.summary)
Matt W383 }
Matt W384 }
Matt W385 }
Matt W386 }
Matt W387 td .filelist-change {
Matt W388 @if let Some(c) = &h.change_id {
Matt W389 a .cid href=(format!("{base}/changes/{c}")) {
Matt W390 (cid_parts(c))
Matt W391 }
Matt W392 }
Matt W393 }
Matt W394 td .filelist-when
Matt W395 title=(h.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W396 (crate::views::relative_time(h.when, now))
Matt W397 }
Matt W398 }
Matt W399 None => {
Matt W400 td .filelist-message {}
Matt W401 td .filelist-change {}
Matt W402 td .filelist-when {}
Matt W403 }
Matt W404 }
Matt W405 }
Matt W406 }
Matt W407 }
Matt W408 }
Matt W409 }
Matt W410 }
Matt W411
Matt W412 @if let Some((name, body)) = readme {
Matt W413 article .filelist.readme {
Matt W414 div .readme-head {
Matt W415 span .mono { (name) }
Matt W416 }
Matt W417 div .readme-body.markdown-body { (body) }
Matt W418 }
Matt W419 }
Matt W420 };
Matt W421
Matt W422 match sidebar {
Matt W423 None => main,
Matt W424 Some(s) => html! {
Matt W425 div .columns.columns-repo {
Matt W426 div .columns-main { (main) }
Matt W427 (repo_aside(ctx, s, now))
Matt W428 }
Matt W429 },
Matt W430 }
Matt W431}
Matt W432
Matt W433/// Split a change id into its short prefix and the rest.
Matt W434///
Matt W435/// Twelve characters is the display length the product settled on; the first
Matt W436/// four carry `--identity` because that is the part people actually type and
Matt W437/// paste. Selecting across both halves still copies one string.
Matt W438pub fn cid_parts(change_id: &str) -> Markup {
Matt W439 let shown = &change_id[..12.min(change_id.len())];
Matt W440 let split = 4.min(shown.len());
Matt W441
Matt W442 html! {
Matt W443 span .cid-p { (&shown[..split]) }
Matt W444 span .cid-r { (&shown[split..]) }
Matt W445 }
Matt W446}
Matt W447
Matt W448/// The bookmark switcher.
Matt W449///
Matt W450/// A `<details>` rather than a scripted menu, so it opens and closes with no
Matt W451/// JavaScript at all. Switching keeps the path you are on, which is the whole
Matt W452/// point of switching from a directory page.
Matt W453fn bookmark_switcher(base: &str, current: &str, path: &str, marks: &[MarkRow]) -> Markup {
Matt W454 let target = |name: &str| {
Matt W455 if path.is_empty() {
Matt W456 format!("{base}/tree/{name}/")
Matt W457 } else {
Matt W458 format!("{base}/tree/{name}/{path}")
Matt W459 }
Matt W460 };
Matt W461
Matt W462 html! {
Matt W463 @if marks.len() > 1 {
Matt W464 details .switcher {
Matt W465 summary .btn.btn-mono {
Matt W466 (current)
Matt W467 span .faint aria-hidden="true" { " ▾" }
Matt W468 }
Matt W469 div .switcher-menu {
Matt W470 div .label-condensed.switcher-label { "Bookmarks" }
Matt W471 @for m in marks {
Matt W472 a .switcher-item href=(target(&m.name)) .is-current[m.name == current] {
Matt W473 span .mono { (m.name) }
Matt W474 @if m.protected {
Matt W475 span .bookmark-flag { "protected" }
Matt W476 }
Matt W477 }
Matt W478 }
Matt W479 }
Matt W480 }
Matt W481 } @else {
Matt W482 span .btn.btn-mono.is-static { (current) }
Matt W483 }
Matt W484 }
Matt W485}
Matt W486
Matt W487/// About / Clone / Repo / Bookmarks.
Matt W488fn repo_aside(
Matt W489 ctx: &RepoContext,
Matt W490 s: &RepoSidebar<'_>,
Matt W491 now: chrono::DateTime<chrono::Utc>,
Matt W492) -> Markup {
Matt W493 let base = ctx.base();
Matt W494
Matt W495 // Only facts that exist get a line. A repository nobody has filed an issue
Matt W496 // against should not be told it has zero issues.
Matt W497 let stats: Vec<(&str, String, &str)> = [
Matt W498 ("Open changes", s.open_changes, "var(--open)"),
Matt W499 ("Conflicted", s.conflicted, "var(--conflict)"),
Matt W500 ("Open issues", s.open_issues, "var(--text-dim)"),
Matt W501 ("Contributors", s.contributors, "var(--text-dim)"),
Matt W502 ]
Matt W503 .into_iter()
Matt W504 .filter(|(_, n, _)| *n > 0)
Matt W505 .map(|(k, n, c)| (k, n.to_string(), c))
Matt W506 .chain(std::iter::once((
Matt W507 "Repository size",
Matt W508 human_size(s.size_bytes),
Matt W509 "var(--text-faint)",
Matt W510 )))
Matt W511 .collect();
Matt W512
Matt W513 html! {
Matt W514 aside .columns-aside {
Matt W515 @if let Some(d) = &ctx.repo.description {
Matt W516 div .aside-block {
Matt W517 div .label-condensed { "About" }
Matt W518 div .aside-about { (d) }
Matt W519 }
Matt W520 }
Matt W521
Matt W522 div .aside-block {
Matt W523 div .label-condensed { "Clone" }
Matt W524 @for (label, cmd) in [("jj", format!("jj git clone {}", s.https)),
Matt W525 ("ssh", format!("jj git clone {}", s.ssh))] {
Matt W526 div .aside-clone {
Matt W527 span .label-condensed { (label) }
Matt W528 code { (cmd) }
Matt W529 }
Matt W530 }
Matt W531 }
Matt W532
Matt W533 div .aside-block {
Matt W534 div .label-condensed { "Repository" }
Matt W535 @for (k, v, colour) in &stats {
Matt W536 div .dotline {
Matt W537 span .dotline-key { (k) }
Matt W538 span .dotline-val style=(format!("color:{colour}")) { (v) }
Matt W539 }
Matt W540 }
Matt W541 }
Matt W542
Matt W543 @if !s.bookmarks.is_empty() {
Matt W544 div .aside-block {
Matt W545 div .aside-head {
Matt W546 div .label-condensed { "Bookmarks" }
Matt W547 span .spacer {}
Matt W548 a href=(format!("{base}/bookmarks")) style="font-size:var(--text-xs)" {
Matt W549 "all →"
Matt W550 }
Matt W551 }
Matt W552 @for m in s.bookmarks.iter().take(6) {
Matt W553 div .bookmark-line {
Matt W554 span .chip { (m.name) }
Matt W555 @if m.protected {
Matt W556 span .bookmark-flag { "protected" }
Matt W557 }
Matt W558 span .spacer {}
Matt W559 span .mini-age
Matt W560 title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W561 (crate::views::relative_time(m.updated_at, now))
Matt W562 }
Matt W563 }
Matt W564 }
Matt W565 }
Matt W566 }
Matt W567 }
Matt W568 }
Matt W569}
Matt W570
Matt W571/// How a markdown file is being shown.
Matt W572///
Matt W573/// `None` means the file is not markdown, so no toggle appears at all.
Matt W574pub enum MarkdownView<'a> {
Matt W575 /// Showing the rendered document.
Matt W576 Rendered(&'a Markup),
Matt W577 /// Showing the highlighted source.
Matt W578 Source,
Matt W579}
Matt W580
Matt W581/// Extra data passed to the blob view for the enhanced code view layout.
Matt W582pub struct BlobExtras<'a> {
Matt W583 /// File tree entries for the sidebar (the directory containing this file).
Matt W584 pub sidebar_entries: &'a [TreeEntry],
Matt W585 /// The directory shown in the sidebar.
Matt W586 pub sidebar_dir: &'a str,
Matt W587 /// Symbols extracted from the file for the outline panel.
Matt W588 pub symbols: &'a [df_render::symbols::Symbol],
Matt W589 /// The last commit that modified this file.
Matt W590 pub last_commit: Option<&'a df_store::Revision>,
Matt W591 /// The account that commit's author email resolved to, when it matched one.
Matt W592 pub last_commit_handle: Option<&'a str>,
Matt W593 /// Per-line blame data (when the user toggled blame on).
Matt W594 pub blame: Option<&'a [df_store::BlameLine]>,
Matt W595 /// Whether blame was requested.
Matt W596 pub wants_blame: bool,
Matt W597}
Matt W598
Matt W599impl<'a> Default for BlobExtras<'a> {
Matt W600 fn default() -> Self {
Matt W601 BlobExtras {
Matt W602 sidebar_entries: &[],
Matt W603 sidebar_dir: "",
Matt W604 symbols: &[],
Matt W605 last_commit: None,
Matt W606 last_commit_handle: None,
Matt W607 blame: None,
Matt W608 wants_blame: false,
Matt W609 }
Matt W610 }
Matt W611}
Matt W612
Matt W613/// A single file.
Matt W614///
Matt W615/// The rendered/source toggle is two real URLs rather than a scripted control,
Matt W616/// so it works with JavaScript disabled like the rest of the product (spec §7)
Matt W617/// and each view can be linked to directly.
Matt W618///
Matt W619/// The toggle says "source" rather than the design's "raw" because this page
Matt W620/// already has a Raw button that downloads the file. Two different controls
Matt W621/// both labelled "raw" on one page would be a worse outcome than the wording
Matt W622/// drifting from the mock.
Matt W623pub fn blob_view(
Matt W624 ctx: &RepoContext,
Matt W625 rev_label: &str,
Matt W626 path: &str,
Matt W627 body: BlobBody<'_>,
Matt W628 markdown: Option<MarkdownView<'_>>,
Matt W629 extras: &BlobExtras<'_>,
Matt W630) -> Markup {
Matt W631 let base = ctx.base();
Matt W632 let raw = format!("{base}/raw/{rev_label}/{path}");
Matt W633 let here = format!("{base}/blob/{rev_label}/{path}");
Matt W634 // Binary and oversized files have no editable representation.
Matt W635 let editable = matches!(body, BlobBody::Text { .. });
Matt W636 let showing_source = matches!(markdown, Some(MarkdownView::Source));
Matt W637
Matt W638 html! {
Matt W639 // Last commit bar for this file.
Matt W640 @if let Some(lc) = extras.last_commit {
Matt W641 div .file-commit-bar {
Matt W642 (avatar(extras.last_commit_handle.unwrap_or(&lc.author.name)))
Matt W643 span .commit-bar-author {
Matt W644 (crate::views::person(extras.last_commit_handle, Some(&lc.author.name)))
Matt W645 }
Matt W646 span .commit-bar-msg { (lc.summary()) }
Matt W647 @if let Some(c) = &lc.change_id {
Matt W648 span .chip.chip-change title="change id" {
Matt W649 (&c[..12.min(c.len())])
Matt W650 }
Matt W651 }
Matt W652 span .faint.tnum
Matt W653 title=(lc.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W654 (crate::views::relative_time(lc.author.when, chrono::Utc::now()))
Matt W655 }
Matt W656 }
Matt W657 }
Matt W658
Matt W659 div .code-view-layout {
Matt W660 // ─── Left sidebar: file tree ─────────────────────────────────
Matt W661 aside .code-sidebar-left aria-label="File tree" {
Matt W662 div .sidebar-header {
Matt W663 (dir_icon())
Matt W664 span .mono.dim {
Matt W665 @if extras.sidebar_dir.is_empty() {
Matt W666 "/"
Matt W667 } @else {
Matt W668 (extras.sidebar_dir)
Matt W669 }
Matt W670 }
Matt W671 }
Matt W672 nav .file-tree {
Matt W673 @if !extras.sidebar_dir.is_empty() {
Matt W674 a .file-tree-item.file-tree-parent href=(
Matt W675 parent_link(&base, rev_label, extras.sidebar_dir)
Matt W676 ) {
Matt W677 (dir_icon()) ".."
Matt W678 }
Matt W679 }
Matt W680 @for e in extras.sidebar_entries {
Matt W681 @let is_current = e.path == path;
Matt W682 a .file-tree-item
Matt W683 .is-dir[e.is_dir()]
Matt W684 .is-current[is_current]
Matt W685 href=(entry_link(&base, rev_label, e))
Matt W686 aria-current=[is_current.then_some("page")] {
Matt W687 @if e.is_dir() { (dir_icon()) } @else { (file_icon()) }
Matt W688 (e.name)
Matt W689 }
Matt W690 }
Matt W691 }
Matt W692 }
Matt W693
Matt W694 // ─── Center: code panel ──────────────────────────────────────
Matt W695 div .code-panel {
Matt W696 div .panel {
Matt W697 div .row style="margin-bottom:12px" {
Matt W698 span .chip { (rev_label) }
Matt W699 (breadcrumbs(&base, rev_label, path))
Matt W700 span style="margin-left:auto" {}
Matt W701
Matt W702 // Blame toggle
Matt W703 @if editable {
Matt W704 @if extras.wants_blame {
Matt W705 a .btn.btn-sm href=(here.clone()) { "Hide Blame" }
Matt W706 } @else {
Matt W707 a .btn.btn-sm href=(format!("{here}?blame=1")) { "Blame" }
Matt W708 }
Matt W709 }
Matt W710
Matt W711 @if markdown.is_some() {
Matt W712 div .segmented role="group" aria-label="Markdown view" {
Matt W713 a .segmented-item .is-on[!showing_source] href=(here.clone()) {
Matt W714 "rendered"
Matt W715 }
Matt W716 a .segmented-item .is-on[showing_source]
Matt W717 href=(format!("{here}?view=source")) {
Matt W718 "source"
Matt W719 }
Matt W720 }
Matt W721 }
Matt W722
Matt W723 // Editing needs push access and a text file. Offered only where
Matt W724 // it would actually work, rather than shown and then refused.
Matt W725 @if ctx.access.can_push() && !ctx.repo.archived && editable {
Matt W726 a .btn href=(format!("{base}/edit/{rev_label}/{path}")) { "Edit" }
Matt W727 }
Matt W728 a .btn href=(raw) { "Raw" }
Matt W729 }
Matt W730
Matt W731 @if let Some(MarkdownView::Rendered(doc)) = &markdown {
Matt W732 div .readme-body.markdown-body { (doc) }
Matt W733 } @else {
Matt W734 @match body {
Matt W735 BlobBody::Text { content, lines, highlighted, language, plain_reason } => {
Matt W736 div .codeblock {
Matt W737 table .mono .codetable {
Matt W738 tbody {
Matt W739 @for (n, line) in content.lines().enumerate() {
Matt W740 tr id=(format!("L{}", n + 1)) {
Matt W741 // Blame gutter (when active)
Matt W742 @if let Some(blame) = extras.blame {
Matt W743 @if let Some(bl) = blame.get(n) {
Matt W744 td .blame-cell title=(format!("{}{}", bl.author, bl.summary)) {
Matt W745 span .blame-author {
Matt W746 (bl.author.chars().take(12).collect::<String>())
Matt W747 }
Matt W748 }
Matt W749 } @else {
Matt W750 td .blame-cell {}
Matt W751 }
Matt W752 }
Matt W753 td .faint .lineno {
Matt W754 a href=(format!("#L{}", n + 1)) { (n + 1) }
Matt W755 }
Matt W756 td .codeline {
Matt W757 @match highlighted.get(n) {
Matt W758 Some(h) => (maud::PreEscaped(h.as_str())),
Matt W759 None => (line),
Matt W760 }
Matt W761 }
Matt W762 }
Matt W763 }
Matt W764 }
Matt W765 }
Matt W766 }
Matt W767 p .hint {
Matt W768 (lines) " lines"
Matt W769 @if let Some(l) = language { " · " (l) }
Matt W770 @if let Some(why) = plain_reason { " · " (why) }
Matt W771 }
Matt W772 }
Matt W773 BlobBody::Binary { size } => {
Matt W774 div .empty {
Matt W775 h2 { "Binary file" }
Matt W776 p { (human_size(size)) " — not shown." }
Matt W777 p { a .btn href=(raw) { "Download" } }
Matt W778 }
Matt W779 }
Matt W780 BlobBody::TooLarge { size, limit } => {
Matt W781 div .empty {
Matt W782 h2 { "File is too large to display" }
Matt W783 p { (human_size(size)) ", over the " (human_size(limit)) " render limit." }
Matt W784 p { a .btn href=(raw) { "Download" } }
Matt W785 }
Matt W786 }
Matt W787 }
Matt W788 }
Matt W789 }
Matt W790 }
Matt W791
Matt W792 // ─── Right sidebar: symbols outline ──────────────────────────
Matt W793 @if !extras.symbols.is_empty() {
Matt W794 aside .code-sidebar-right aria-label="Symbol outline" {
Matt W795 div .sidebar-header {
Matt W796 span .label-condensed { "Symbols" }
Matt W797 }
Matt W798 nav .symbol-list {
Matt W799 @for sym in extras.symbols {
Matt W800 a .symbol-item href=(format!("#L{}", sym.line)) {
Matt W801 span .symbol-kind class=(format!("sk-{}", sym.kind.css_class())) {
Matt W802 (sym.kind.label())
Matt W803 }
Matt W804 span .symbol-name { (sym.name) }
Matt W805 }
Matt W806 }
Matt W807 }
Matt W808 }
Matt W809 }
Matt W810 }
Matt W811 }
Matt W812}
Matt W813
Matt W814pub enum BlobBody<'a> {
Matt W815 Text {
Matt W816 content: &'a str,
Matt W817 lines: usize,
Matt W818 /// Per-line highlighted HTML. Empty when the file rendered plain, and
Matt W819 /// indexed by line so a short list degrades line-by-line rather than
Matt W820 /// misaligning the whole file.
Matt W821 highlighted: &'a [String],
Matt W822 language: Option<&'a str>,
Matt W823 /// Why highlighting was skipped, when it was.
Matt W824 plain_reason: Option<&'a str>,
Matt W825 },
Matt W826 Binary { size: u64 },
Matt W827 TooLarge { size: u64, limit: u64 },
Matt W828}
Matt W829
Matt W830/// Commit log.
Matt W831/// The commit log. `handles` maps commit-author email to a Dogfood handle for
Matt W832/// the authors that have accounts; everyone else renders as the name the commit
Matt W833/// carries.
Matt W834pub fn log_view(
Matt W835 ctx: &RepoContext,
Matt W836 rev_label: &str,
Matt W837 revisions: &[Revision],
Matt W838 handles: &std::collections::HashMap<String, String>,
Matt W839) -> Markup {
Matt W840 let base = ctx.base();
Matt W841 html! {
Matt W842 div .panel {
Matt W843 div .row style="margin-bottom:12px" {
Matt W844 span .label-condensed { "History" }
Matt W845 span .chip { (rev_label) }
Matt W846 }
Matt W847 @if revisions.is_empty() {
Matt W848 p .dim { "No history." }
Matt W849 } @else {
Matt W850 div .stack style="gap:0" {
Matt W851 @for r in revisions {
Matt W852 div style="padding:10px 0;border-bottom:1px solid var(--border)" {
Matt W853 div .row {
Matt W854 // The message leads to the commit's own page —
Matt W855 // what it changed is the question a log row
Matt W856 // raises, and the tree at that revision is not
Matt W857 // an answer to it.
Matt W858 a href=(format!("{base}/commit/{}", r.rev)) { (r.summary()) }
Matt W859 @if r.conflicted {
Matt W860 span .badge.badge-conflict { "conflict" }
Matt W861 }
Matt W862 }
Matt W863 div .row style="margin-top:4px;gap:8px" {
Matt W864 // The change id is the identity; the revision is
Matt W865 // a point in its history (spec §4).
Matt W866 @if let Some(c) = &r.change_id {
Matt W867 span .chip.chip-change title="jj change id" {
Matt W868 (&c[..12.min(c.len())])
Matt W869 }
Matt W870 } @else {
Matt W871 span .chip title="authored with plain git" { "git" }
Matt W872 }
Matt W873 a .mono.faint href=(format!("{base}/commit/{}", r.rev))
Matt W874 title=(r.rev.as_str()) {
Matt W875 (df_store::abbreviate_rev(r.rev.as_str()))
Matt W876 }
Matt W877 span .faint {
Matt W878 (crate::views::person(
Matt W879 handles.get(&r.author.email).map(String::as_str),
Matt W880 Some(&r.author.name),
Matt W881 ))
Matt W882 }
Matt W883 span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
Matt W884 }
Matt W885 }
Matt W886 }
Matt W887 }
Matt W888 }
Matt W889 }
Matt W890 }
Matt W891}
Matt W892
Matt W893// ─── one commit ──────────────────────────────────────────────────────────────
Matt W894
Matt W895/// Everything the commit page shows about one revision.
Matt W896pub struct CommitPage<'a> {
Matt W897 pub rev: &'a Revision,
Matt W898 /// The account the author's email resolved to, when it matched one.
Matt W899 pub author_handle: Option<&'a str>,
Matt W900 /// The account the committer's email resolved to. Only rendered when the
Matt W901 /// committer differs from the author — on a rebase or an amend they part
Matt W902 /// company, and that is exactly when a reader wants to know.
Matt W903 pub committer_handle: Option<&'a str>,
Matt W904 /// The patch against the first parent. `None` when the diff exceeded the
Matt W905 /// store's limits or could not be read.
Matt W906 pub diff: Option<&'a df_store::Diff>,
Matt W907 /// Fold every file, for skimming the shape of a large commit first.
Matt W908 pub collapsed: bool,
Matt W909}
Matt W910
Matt W911/// One commit: what it says, who wrote it, where it sits in history, and what
Matt W912/// it changed.
Matt W913///
Matt W914/// The diff is against the first parent, which is what makes a merge readable
Matt W915/// as "what this merge brought in" rather than as a second copy of both sides.
Matt W916pub fn commit_view(ctx: &RepoContext, c: CommitPage<'_>) -> Markup {
Matt W917 use crate::views::diff as vd;
Matt W918
Matt W919 let base = ctx.base();
Matt W920 let rev = c.rev.rev.as_str();
Matt W921 let blob_base = format!("{base}/blob/{rev}");
Matt W922 let now = chrono::Utc::now();
Matt W923 // The commit's own hash, not a bookmark name: a page about one commit must
Matt W924 // keep pointing at that commit after the bookmark moves.
Matt W925 let toggle = if c.collapsed {
Matt W926 format!("{base}/commit/{rev}")
Matt W927 } else {
Matt W928 format!("{base}/commit/{rev}?collapse=1")
Matt W929 };
Matt W930
Matt W931 // A different committer is worth a line; the usual case, where they are the
Matt W932 // same person, is not.
Matt W933 let amended = c.rev.committer.email != c.rev.author.email;
Matt W934
Matt W935 html! {
Matt W936 div .panel .commit-meta {
Matt W937 div .commit-msg {
Matt W938 h1 { (c.rev.summary()) }
Matt W939 @if !c.rev.body().is_empty() {
Matt W940 pre .commit-body { (c.rev.body()) }
Matt W941 }
Matt W942 }
Matt W943
Matt W944 div .commit-byline {
Matt W945 (avatar(c.author_handle.unwrap_or(&c.rev.author.name)))
Matt W946 span {
Matt W947 (crate::views::person(c.author_handle, Some(&c.rev.author.name)))
Matt W948 " authored "
Matt W949 span .faint title=(c.rev.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W950 (crate::views::relative_time(c.rev.author.when, now))
Matt W951 }
Matt W952 }
Matt W953 @if amended {
Matt W954 span .faint {
Matt W955 "· committed by "
Matt W956 (crate::views::person(c.committer_handle, Some(&c.rev.committer.name)))
Matt W957 " "
Matt W958 span title=(c.rev.committer.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W959 (crate::views::relative_time(c.rev.committer.when, now))
Matt W960 }
Matt W961 }
Matt W962 }
Matt W963 @if c.rev.conflicted {
Matt W964 span .badge.badge-conflict { "conflict" }
Matt W965 }
Matt W966 }
Matt W967
Matt W968 div .commit-ids {
Matt W969 // The change id is the identity; the revision is a point in its
Matt W970 // history (spec §4). Both are shown, and the identity links.
Matt W971 @if let Some(id) = &c.rev.change_id {
Matt W972 a .cid href=(format!("{base}/changes/{id}"))
Matt W973 title=(format!("jj change id: {id}")) { (cid_parts(id)) }
Matt W974 } @else {
Matt W975 span .chip title="authored with plain git" { "git" }
Matt W976 }
Matt W977 span .mono.faint title=(rev) { (df_store::abbreviate_rev(rev)) }
Matt W978
Matt W979 span .spacer {}
Matt W980
Matt W981 @for p in &c.rev.parents {
Matt W982 a .mono.commit-parent href=(format!("{base}/commit/{p}"))
Matt W983 title=(format!("parent {p}")) {
Matt W984 "parent " (df_store::abbreviate_rev(p.as_str()))
Matt W985 }
Matt W986 }
Matt W987 a href=(format!("{base}/tree/{rev}/")) { "Browse files" }
Matt W988 }
Matt W989 }
Matt W990
Matt W991 @match c.diff {
Matt W992 None => div .panel { p .dim style="margin:0" {
Matt W993 "This commit's diff could not be rendered. Fetch the revision with "
Matt W994 code { "jj" } " to read it in full."
Matt W995 } },
Matt W996 Some(d) => {
Matt W997 div .diffbar {
Matt W998 (vd::stat_summary(d))
Matt W999 span .spacer {}
Matt W1000 @if !d.files.is_empty() {
Matt W1001 a .diffbar-link href=(toggle) {
Matt W1002 (if c.collapsed { "Expand all" } else { "Collapse all" })
Matt W1003 }
Matt W1004 }
Matt W1005 }
Matt W1006 // The tree stands in for the flat file index here: it answers
Matt W1007 // the same "which files" question and answers it better, and
Matt W1008 // stacking both above the diff would make the reader scroll
Matt W1009 // past the same thirty paths twice.
Matt W1010 div .difflayout {
Matt W1011 (vd::tree(d))
Matt W1012 div .diffmain {
Matt W1013 (vd::files(&vd::DiffView {
Matt W1014 collapsed: c.collapsed,
Matt W1015 // A commit is a revision the browse routes can
Matt W1016 // serve, so every file header reaches the whole
Matt W1017 // file at this point in history — the step a hunk
Matt W1018 // always raises.
Matt W1019 blob_base: Some(&blob_base),
Matt W1020 ..vd::DiffView::new(d)
Matt W1021 }))
Matt W1022 }
Matt W1023 }
Matt W1024 }
Matt W1025 }
Matt W1026 }
Matt W1027}
Matt W1028
Matt W1029/// Bookmark list.
Matt W1030/// The bookmarks page.
Matt W1031///
Matt W1032/// Four columns, and the second one is the argument: a bookmark *points at* a
Matt W1033/// change. The name in column one can move to any other row tomorrow; the id in
Matt W1034/// column two is what the review, the approvals and the permalinks are attached
Matt W1035/// to. The page exists to make that asymmetry visible.
Matt W1036pub fn bookmarks_view(ctx: &RepoContext, marks: &[MarkRow]) -> Markup {
Matt W1037 let base = ctx.base();
Matt W1038 let now = chrono::Utc::now();
Matt W1039
Matt W1040 html! {
Matt W1041 div .page-head {
Matt W1042 div {
Matt W1043 h1 { "Bookmarks" }
Matt W1044 p .dim.section-note {
Matt W1045 "Bookmarks are movable pointers, not identities. Reviews attach to changes."
Matt W1046 }
Matt W1047 }
Matt W1048 }
Matt W1049
Matt W1050 @if marks.is_empty() {
Matt W1051 div .empty {
Matt W1052 h2 { "No bookmarks yet" }
Matt W1053 p { "Push one with " code { "jj git push --bookmark <name>" } "." }
Matt W1054 }
Matt W1055 } @else {
Matt W1056 div .filelist {
Matt W1057 table .bookmark-table {
Matt W1058 caption .sr-only { "Bookmarks in this repository" }
Matt W1059 thead {
Matt W1060 tr {
Matt W1061 th { "Bookmark" }
Matt W1062 th { "Points at" }
Matt W1063 th { "Title" }
Matt W1064 th { "Updated" }
Matt W1065 }
Matt W1066 }
Matt W1067 tbody {
Matt W1068 @for m in marks {
Matt W1069 tr {
Matt W1070 td .bookmark-name {
Matt W1071 a .mono href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
Matt W1072 @if m.name == ctx.repo.default_bookmark {
Matt W1073 span .bookmark-flag { "default" }
Matt W1074 }
Matt W1075 @if m.protected {
Matt W1076 span .bookmark-flag { "protected" }
Matt W1077 }
Matt W1078 }
Matt W1079 td .bookmark-points {
Matt W1080 @match (&m.change_id, m.number) {
Matt W1081 (Some(c), Some(n)) => {
Matt W1082 a .cid href=(format!("{base}/changes/{n}"))
Matt W1083 title=(format!("jj change id: {c}")) {
Matt W1084 (cid_parts(c))
Matt W1085 }
Matt W1086 }
Matt W1087 // A bookmark the indexer has not caught
Matt W1088 // up with. Saying so beats an empty cell
Matt W1089 // that reads as a rendering bug.
Matt W1090 _ => span .faint.mono { "not indexed" },
Matt W1091 }
Matt W1092 }
Matt W1093 td .bookmark-title {
Matt W1094 @if let Some(t) = &m.title { (t) }
Matt W1095 }
Matt W1096 td .bookmark-when
Matt W1097 title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W1098 (crate::views::relative_time(m.updated_at, now))
Matt W1099 }
Matt W1100 }
Matt W1101 }
Matt W1102 }
Matt W1103 }
Matt W1104 }
Matt W1105 }
Matt W1106 }
Matt W1107}
Matt W1108
Matt W1109/// New-repository form.
Matt W1110pub fn new_repo_form(csrf: &str, error: Option<&str>, owners: &[String]) -> Markup {
Matt W1111 html! {
Matt W1112 div .panel {
Matt W1113 h1 { "New repository" }
Matt W1114 @if let Some(e) = error {
Matt W1115 div .banner.banner-error role="alert" { (e) }
Matt W1116 }
Matt W1117 form method="post" action="/repos" {
Matt W1118 input type="hidden" name="_csrf" value=(csrf);
Matt W1119
Matt W1120 div .field {
Matt W1121 label for="owner" { "Owner" }
Matt W1122 select id="owner" name="owner"
Matt W1123 style="padding:7px 10px;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius);color:var(--text);font:inherit" {
Matt W1124 @for o in owners {
Matt W1125 option value=(o) { (o) }
Matt W1126 }
Matt W1127 }
Matt W1128 }
Matt W1129
Matt W1130 div .field {
Matt W1131 label for="name" { "Repository name" }
Matt W1132 input type="text" id="name" name="name" required
Matt W1133 maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._\\-]*"
Matt W1134 autocomplete="off" autofocus;
Matt W1135 p .hint { "Letters, digits, dots, hyphens and underscores." }
Matt W1136 }
Matt W1137
Matt W1138 div .field {
Matt W1139 label for="description" { "Description (optional)" }
Matt W1140 input type="text" id="description" name="description" maxlength="500";
Matt W1141 }
Matt W1142
Matt W1143 div .field {
Matt W1144 label for="default_bookmark" { "Default bookmark" }
Matt W1145 input type="text" id="default_bookmark" name="default_bookmark"
Matt W1146 value="main" maxlength="100" required;
Matt W1147 }
Matt W1148
Matt W1149 div .field {
Matt W1150 label {
Matt W1151 input type="checkbox" name="private" value="1" checked
Matt W1152 style="width:auto;margin-right:8px";
Matt W1153 "Private"
Matt W1154 }
Matt W1155 }
Matt W1156
Matt W1157 button .btn.btn-primary type="submit" { "Create repository" }
Matt W1158 }
Matt W1159 }
Matt W1160 }
Matt W1161}
Matt W1162
Matt W1163// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W1164
Matt W1165fn entry_link(base: &str, rev: &str, e: &TreeEntry) -> String {
Matt W1166 let kind = if e.is_dir() { "tree" } else { "blob" };
Matt W1167 format!("{base}/{kind}/{rev}/{}", e.path)
Matt W1168}
Matt W1169
Matt W1170fn parent_link(base: &str, rev: &str, path: &str) -> String {
Matt W1171 let parent = match path.rsplit_once('/') {
Matt W1172 Some((p, _)) => p,
Matt W1173 None => "",
Matt W1174 };
Matt W1175 format!("{base}/tree/{rev}/{parent}")
Matt W1176}
Matt W1177
Matt W1178pub fn breadcrumbs(base: &str, rev: &str, path: &str) -> Markup {
Matt W1179 html! {
Matt W1180 span {
Matt W1181 a href=(format!("{base}/tree/{rev}/")) { "root" }
Matt W1182 @for (label, acc) in df_store::path::breadcrumbs(path) {
Matt W1183 span .faint { " / " }
Matt W1184 a href=(format!("{base}/tree/{rev}/{acc}")) { (label) }
Matt W1185 }
Matt W1186 }
Matt W1187 }
Matt W1188}
Matt W1189
Matt W1190/// Human-readable byte count.
Matt W1191pub fn human_size(n: u64) -> String {
Matt W1192 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
Matt W1193 let mut v = n as f64;
Matt W1194 let mut i = 0;
Matt W1195 while v >= 1024.0 && i < UNITS.len() - 1 {
Matt W1196 v /= 1024.0;
Matt W1197 i += 1;
Matt W1198 }
Matt W1199 if i == 0 {
Matt W1200 format!("{n} B")
Matt W1201 } else {
Matt W1202 format!("{v:.1} {}", UNITS[i])
Matt W1203 }
Matt W1204}
Matt W1205
Matt W1206/// Markdown that has already been sanitised by `df-render`.
Matt W1207pub fn rendered_markdown(html_str: &str) -> Markup {
Matt W1208 PreEscaped(html_str.to_string())
Matt W1209}
Matt W1210
Matt W1211#[cfg(test)]
Matt W1212mod tests {
Matt W1213 use super::*;
Matt W1214
Matt W1215 #[test]
Matt W1216 fn human_size_is_readable() {
Matt W1217 assert_eq!(human_size(0), "0 B");
Matt W1218 assert_eq!(human_size(512), "512 B");
Matt W1219 assert_eq!(human_size(1024), "1.0 KB");
Matt W1220 assert_eq!(human_size(1536), "1.5 KB");
Matt W1221 assert_eq!(human_size(1024 * 1024), "1.0 MB");
Matt W1222 }
Matt W1223
Matt W1224 #[test]
Matt W1225 fn parent_link_walks_up_one_level() {
Matt W1226 assert_eq!(parent_link("/o/r", "main", "a/b/c"), "/o/r/tree/main/a/b");
Matt W1227 assert_eq!(parent_link("/o/r", "main", "a"), "/o/r/tree/main/");
Matt W1228 }
Matt W1229
Matt W1230 // ─── the commit page ─────────────────────────────────────────────────────
Matt W1231
Matt W1232 fn ctx() -> RepoContext {
Matt W1233 RepoContext {
Matt W1234 repo: df_db::models::Repo {
Matt W1235 id: uuid::Uuid::nil(),
Matt W1236 owner_kind: df_db::models::OwnerKind::User,
Matt W1237 owner_user_id: None,
Matt W1238 owner_org_id: None,
Matt W1239 name: "r".into(),
Matt W1240 description: None,
Matt W1241 visibility: df_db::models::Visibility::Public,
Matt W1242 default_bookmark: "main".into(),
Matt W1243 fork_of_repo_id: None,
Matt W1244 size_bytes: 0,
Matt W1245 pushed_at: None,
Matt W1246 archived: false,
Matt W1247 created_at: chrono::Utc::now(),
Matt W1248 },
Matt W1249 owner: "o".into(),
Matt W1250 access: df_auth::RepoAccess::DENIED,
Matt W1251 nav: crate::repo_ctx::RepoNav::default(),
Matt W1252 }
Matt W1253 }
Matt W1254
Matt W1255 fn sig(name: &str) -> df_store::Signature {
Matt W1256 df_store::Signature {
Matt W1257 name: name.into(),
Matt W1258 email: format!("{name}@example.test"),
Matt W1259 when: chrono::Utc::now(),
Matt W1260 }
Matt W1261 }
Matt W1262
Matt W1263 fn revision() -> Revision {
Matt W1264 Revision {
Matt W1265 rev: df_store::RevId::from_stored(
Matt W1266 "0123456789abcdef0123456789abcdef01234567".to_string(),
Matt W1267 ),
Matt W1268 change_id: Some("kksontuqryot".into()),
Matt W1269 parents: vec![df_store::RevId::from_stored(
Matt W1270 "fedcba9876543210fedcba9876543210fedcba98".to_string(),
Matt W1271 )],
Matt W1272 author: sig("alice"),
Matt W1273 committer: sig("alice"),
Matt W1274 message: "fix the thing\n\nA longer explanation.\n".into(),
Matt W1275 conflicted: false,
Matt W1276 conflict_sides: Vec::new(),
Matt W1277 conflict_bases: Vec::new(),
Matt W1278 }
Matt W1279 }
Matt W1280
Matt W1281 fn page(rev: &Revision, diff: Option<&df_store::Diff>) -> String {
Matt W1282 commit_view(
Matt W1283 &ctx(),
Matt W1284 CommitPage {
Matt W1285 rev,
Matt W1286 author_handle: Some("alice"),
Matt W1287 committer_handle: Some("alice"),
Matt W1288 diff,
Matt W1289 collapsed: false,
Matt W1290 },
Matt W1291 )
Matt W1292 .into_string()
Matt W1293 }
Matt W1294
Matt W1295 /// The three things a commit page is for: what it says, which commit it is,
Matt W1296 /// and what it changed.
Matt W1297 #[test]
Matt W1298 fn a_commit_shows_its_message_its_ids_and_its_patch() {
Matt W1299 let r = revision();
Matt W1300 let d = crate::views::diff::tests::fixture(6);
Matt W1301 let html = page(&r, Some(&d));
Matt W1302
Matt W1303 assert!(html.contains("fix the thing"));
Matt W1304 assert!(html.contains("A longer explanation."));
Matt W1305 // Abbreviation goes through the store (spec §3 rule 2) — never a slice.
Matt W1306 assert!(html.contains("0123456789ab"), "{html:.800}");
Matt W1307 assert!(!html.contains("0123456789abcdef0123456789abcdef01234567<"));
Matt W1308 // The change id is the identity, and it links to the change.
Matt W1309 assert!(html.contains("href=\"/o/r/changes/kksontuqryot\""));
Matt W1310 assert!(html.contains("difftable"));
Matt W1311 }
Matt W1312
Matt W1313 /// The parent is the other half of "what changed": the diff is *against* it,
Matt W1314 /// so walking back one commit has to be one click.
Matt W1315 #[test]
Matt W1316 fn parents_link_to_their_own_commit_pages() {
Matt W1317 let r = revision();
Matt W1318 let html = page(&r, None);
Matt W1319 assert!(
Matt W1320 html.contains(
Matt W1321 "href=\"/o/r/commit/fedcba9876543210fedcba9876543210fedcba98\""
Matt W1322 ),
Matt W1323 "{html:.800}"
Matt W1324 );
Matt W1325 }
Matt W1326
Matt W1327 /// A commit whose patch could not be rendered still has a message, an
Matt W1328 /// author and parents worth reading.
Matt W1329 #[test]
Matt W1330 fn a_commit_with_no_renderable_diff_still_renders() {
Matt W1331 let html = page(&revision(), None);
Matt W1332 assert!(html.contains("fix the thing"));
Matt W1333 assert!(html.contains("could not be rendered"));
Matt W1334 assert!(!html.contains("difftable"));
Matt W1335 }
Matt W1336
Matt W1337 /// The collapse link is a URL, so a folded commit can be pasted at
Matt W1338 /// somebody — and it points back at the commit's own hash rather than at
Matt W1339 /// whatever bookmark was typed to reach it.
Matt W1340 #[test]
Matt W1341 fn the_collapse_toggle_is_a_permalink() {
Matt W1342 let r = revision();
Matt W1343 let d = crate::views::diff::tests::fixture(6);
Matt W1344 let expanded = page(&r, Some(&d));
Matt W1345 assert!(expanded.contains(&format!("/o/r/commit/{}?collapse=1", r.rev)));
Matt W1346
Matt W1347 let folded = commit_view(
Matt W1348 &ctx(),
Matt W1349 CommitPage {
Matt W1350 rev: &r,
Matt W1351 author_handle: None,
Matt W1352 committer_handle: None,
Matt W1353 diff: Some(&d),
Matt W1354 collapsed: true,
Matt W1355 },
Matt W1356 )
Matt W1357 .into_string();
Matt W1358 assert!(folded.contains("Expand all"));
Matt W1359 assert!(!folded.contains("collapse=1"));
Matt W1360 }
Matt W1361
Matt W1362 /// An amend or a rebase parts the author from the committer, and that is
Matt W1363 /// exactly when the second name is worth the line it costs.
Matt W1364 #[test]
Matt W1365 fn a_committer_is_only_named_when_they_differ_from_the_author() {
Matt W1366 assert!(!page(&revision(), None).contains("committed by"));
Matt W1367
Matt W1368 let mut r = revision();
Matt W1369 r.committer = sig("bob");
Matt W1370 assert!(page(&r, None).contains("committed by"));
Matt W1371 }
Matt W1372}

1372 lines · Rust