diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 209c2991..01761bd0 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -28,12 +28,29 @@ pub enum Truncation { Budget, } +/// What a commit is called, for a header that would otherwise have eight hex +/// digits and nothing else to say. +/// +/// Rides along on [`DiffSource::Commit`] and takes no part in its identity — +/// see [`DiffSource::tag`]. Defined here rather than in [`log`](super::log) so +/// the dependency between the two modules stays one-way: `log` reaches into +/// `diff` for [`FileStatus`], and nothing goes back. That is also why the +/// timestamp is a bare unix second rather than an +/// [`OffsetTs`](super::log::OffsetTs) — relative time is all the header shows. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct CommitLabel { + pub subject: String, + pub author: String, + /// Author time, unix seconds. Zero where it is not known. + pub at: i64, +} + /// Which patch to ask git for. /// /// The three working-tree variants are the same three questions the SCM panel /// asks — `Worktree` is what is not staged, `Staged` is what is, and `Head` is /// both at once, which is what the overlay has always shown. -#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] +#[derive(Clone, Debug, Default)] pub enum DiffSource { /// `git diff` — unstaged changes. Worktree, @@ -42,13 +59,59 @@ pub enum DiffSource { /// `git diff HEAD` — staged and unstaged together. #[default] Head, - /// One commit against its first parent. - Commit { rev: String }, + /// One commit against its first parent, and optionally what to call it. + Commit { + rev: String, + label: Option, + }, /// `base...head`: what `head` added since the two diverged. Range { base: String, head: String }, } +impl PartialEq for DiffSource { + fn eq(&self, other: &DiffSource) -> bool { + self.tag() == other.tag() + } +} + +impl Eq for DiffSource {} + +impl std::hash::Hash for DiffSource { + fn hash(&self, state: &mut H) { + self.tag().hash(state); + } +} + impl DiffSource { + /// A commit with nothing known about it yet beyond which one it is. + pub fn commit(rev: impl Into) -> DiffSource { + DiffSource::Commit { + rev: rev.into(), + label: None, + } + } + + /// The identity of the patch, with nothing on it that only affects how the + /// patch is *labelled*. + /// + /// `PartialEq`, `Hash` and the overlay's string cache key are all defined + /// from this one function, so the three cannot drift apart. The rule it + /// exists to enforce: the same commit opened from the graph (with a + /// subject in hand) and from a keybinding (without one) is one patch, one + /// in-flight probe and one overlay. A derived `PartialEq` would make them + /// two, and the derived `Debug` the overlay's key used to be built from + /// would have split the probe cache the same way. + pub fn tag(&self) -> String { + match self { + DiffSource::Worktree => "worktree".to_string(), + DiffSource::Staged => "staged".to_string(), + DiffSource::Head => "head".to_string(), + // US, which can occur in neither a refname nor an object id. + DiffSource::Commit { rev, .. } => format!("commit\u{1f}{rev}"), + DiffSource::Range { base, head } => format!("range\u{1f}{base}\u{1f}{head}"), + } + } + /// The whole argv, minus pathspecs. Every diff tty7 runs is built here so /// there is one place to read, and one place to test, what git is asked. pub fn args(&self, context: u32, ignore_whitespace: bool) -> Vec { @@ -70,7 +133,7 @@ impl DiffSource { // is no special case and no need for `--root`. `--format=` empties // the commit header, at the cost of one blank line the parser // ignores. - DiffSource::Commit { rev } => { + DiffSource::Commit { rev, .. } => { argv.extend(strings(&["log", "-p", "-1", "--format=", "--first-parent"])); argv.push(rev.clone()); } @@ -767,9 +830,7 @@ Binary files a/img.png and b/img.png differ (DiffSource::Staged, vec!["diff", "--cached"]), (DiffSource::Head, vec!["diff", "HEAD"]), ( - DiffSource::Commit { - rev: "deadbeef".into(), - }, + DiffSource::commit("deadbeef"), vec!["log", "-p", "-1", "--format=", "--first-parent", "deadbeef"], ), ( @@ -791,6 +852,73 @@ Binary files a/img.png and b/img.png differ } } + /// The one property the whole label mechanism rests on. Break it and the + /// same commit becomes two probes, two overlays and two cache entries the + /// moment one of them learns its own subject. + #[test] + fn a_commits_label_is_not_part_of_which_commit_it_is() { + let bare = DiffSource::commit("deadbeef"); + let labelled = DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel { + subject: "fix(scm): the thing".into(), + author: "Ada".into(), + at: 1_786_255_391, + }), + }; + let other = DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel { + subject: "something else entirely".into(), + ..Default::default() + }), + }; + + assert_eq!(bare, labelled); + assert_eq!(labelled, other); + assert_eq!(bare.tag(), labelled.tag()); + assert_eq!(hash_of(&bare), hash_of(&labelled)); + assert_eq!(hash_of(&labelled), hash_of(&other)); + // …and the argv, which is the other thing a split cache would show up + // in: two "different" sources running the identical command. + assert_eq!(argv(bare.clone()), argv(labelled)); + + // Which commit it is still separates them, of course. + let elsewhere = DiffSource::commit("cafebabe"); + assert_ne!(bare, elsewhere); + assert_ne!(hash_of(&bare), hash_of(&elsewhere)); + assert_ne!(bare, DiffSource::Head); + assert_ne!( + DiffSource::Range { + base: "a".into(), + head: "b".into() + }, + DiffSource::Range { + base: "b".into(), + head: "a".into() + } + ); + // Every variant is still equal to itself, which `Eq` promises and a + // hand-written `PartialEq` is exactly where it could stop being true. + for source in [ + DiffSource::Worktree, + DiffSource::Staged, + DiffSource::Head, + bare, + elsewhere, + ] { + assert_eq!(source, source.clone(), "{source:?}"); + assert_eq!(hash_of(&source), hash_of(&source.clone())); + } + } + + fn hash_of(source: &DiffSource) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + source.hash(&mut hasher); + hasher.finish() + } + #[test] fn quote_path_is_off_on_every_source() { // Left on, a non-ASCII path arrives as C octal escapes that nothing @@ -802,7 +930,7 @@ Binary files a/img.png and b/img.png differ DiffSource::Worktree, DiffSource::Staged, DiffSource::Head, - DiffSource::Commit { rev: "HEAD".into() }, + DiffSource::commit("HEAD"), DiffSource::Range { base: "a".into(), head: "b".into(), @@ -915,7 +1043,7 @@ Binary files a/img.png and b/img.png differ assert!(DiffSource::Worktree.lists_untracked()); assert!(DiffSource::Head.lists_untracked()); assert!(!DiffSource::Staged.lists_untracked()); - assert!(!DiffSource::Commit { rev: "x".into() }.lists_untracked()); + assert!(!DiffSource::commit("x").lists_untracked()); assert!( !DiffSource::Range { base: "a".into(), @@ -1284,9 +1412,7 @@ index 1..2 100644 fn commit_files(host: &dyn Host, dir: &Path, spec: &str) -> Vec { let req = DiffRequest { - source: DiffSource::Commit { - rev: rev(dir, spec), - }, + source: DiffSource::commit(rev(dir, spec)), ..Default::default() }; probe_diff(host, dir, &req) diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index b0079703..f584b5ad 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -13,6 +13,7 @@ use std::path::Path; use smallvec::SmallVec; use super::RecordSplitter; +use super::diff::FileStatus; use crate::host::Host; /// Full hex object id. Kept as `String` rather than `[u8; 20]` because sha256 @@ -33,6 +34,10 @@ pub const GRAPH_PAGE: usize = 200; pub const MAX_GRAPH_COMMITS: usize = 5_000; pub const MAX_LANES: Lane = 32; pub const MAX_REFS: usize = 2_000; +/// How many changed files one commit's detail view will hold. A vendored +/// dependency landing in a single commit is tens of thousands of paths, and +/// every one of them would become a row. +pub const MAX_COMMIT_FILES: usize = 1_000; pub const MAX_SUBJECT_BYTES: usize = 512; pub const MAX_BODY_BYTES: usize = 8 * 1024; pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024; @@ -79,6 +84,10 @@ pub struct RefDeco { pub short: String, /// Carried the `HEAD -> ` prefix in `%D`. pub is_head: bool, + /// The full refname this branch tracks, where it tracks one. Only + /// [`for_each_ref`] can fill it in — `%D` says nothing about upstreams — + /// so a decoration parsed out of a `log` record always leaves it `None`. + pub upstream: Option, } #[derive(Clone, PartialEq, Eq, Debug)] @@ -496,6 +505,7 @@ fn parse_deco(text: &str) -> Vec { full: "HEAD".to_string(), short: "HEAD".to_string(), is_head: true, + upstream: None, }); continue; } @@ -524,6 +534,7 @@ fn ref_deco(full: &str, is_head: bool) -> Option { full: full.to_string(), short: short.to_string(), is_head, + upstream: None, }) } @@ -538,11 +549,10 @@ pub fn parse_refs(stdout: &[u8]) -> HashMap> { let full = fields.next().unwrap_or_default().trim(); // `%(refname:short)` is skipped in favour of stripping the prefix here, // so a ref named the same way from `%D` and from here reads identically - // in the UI. `%(upstream)` and `%(objecttype)` are asked for because - // the branch switcher will want them off the same call; neither has a - // home on `RefDeco` yet. + // in the UI. `%(objecttype)` is asked for to keep the format one line + // rather than two; only `%(*objectname)` below acts on it. let _short = fields.next(); - let _upstream = fields.next(); + let upstream = fields.next().unwrap_or_default().trim(); let head = fields.next().unwrap_or_default().trim(); let _kind = fields.next(); let peeled = fields.next().unwrap_or_default().trim(); @@ -553,7 +563,8 @@ pub fn parse_refs(stdout: &[u8]) -> HashMap> { if full.is_empty() || !is_hex_oid(target) { continue; } - if let Some(deco) = ref_deco(full, head == "*") { + if let Some(mut deco) = ref_deco(full, head == "*") { + deco.upstream = (!upstream.is_empty()).then(|| upstream.to_string()); out.entry(target.to_string()).or_default().push(deco); } } @@ -577,6 +588,264 @@ pub fn for_each_ref(host: &dyn Host, root: &Path) -> HashMap> } } +/// The local branch names, one per line, in git's own refname order. +/// +/// `for-each-ref` rather than `branch`: no porcelain warnings, no column +/// layout, and one name per line whatever the user's config says. It is a +/// separate call from [`for_each_ref`] because that one groups by the commit a +/// ref points at, which is the wrong shape for a list of branches — the +/// switcher wants every branch, including the ones sharing a tip. +pub fn local_branches(host: &dyn Host, root: &Path) -> Vec { + let count = format!("--count={MAX_REFS}"); + let args = [ + "for-each-ref", + &count, + "--format=%(refname:short)", + "refs/heads", + ]; + match host.git(root, &args) { + Ok(out) if out.success() => parse_branch_names(&out.stdout), + _ => Vec::new(), + } +} + +pub fn parse_branch_names(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .take(MAX_REFS) + .map(str::to_string) + .collect() +} + +/// One commit's metadata, for a detail view that does not already have it. +/// +/// A commit that is on screen in the graph is already a [`Commit`] in +/// [`CommitPage::commits`], and the caller is expected to hand that over +/// instead of paying for this. What is left is the case the page cannot +/// answer: a commit reached from a parent link, or from anywhere outside the +/// window the graph happens to be holding. +pub fn load_commit(host: &dyn Host, root: &Path, rev: &str) -> Option { + if !is_rev(rev) { + return None; + } + let args = [ + "-c", + "log.showSignature=false", + "show", + "--no-patch", + // Without it `%D` prints short names, and `parse_deco` reads full + // ones — every chip would come back as `RefKind::Other`. + "--decorate=full", + "--no-color", + LOG_PRETTY, + rev, + ]; + let out = host.git(root, &args).ok()?; + if !out.success() { + return None; + } + parse_log(&out.stdout).into_iter().next() +} + +/// One path a commit touched, with the line counts beside it. +/// +/// The counts are `Option` rather than `0` because "git did not say" and +/// "nothing changed" are different answers: a binary file reports neither, and +/// a pure rename reports `0 0`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CommitFile { + /// Repository-relative, and for a rename the *new* name. + pub path: String, + /// Where a rename or a copy came from. + pub orig_path: Option, + pub status: FileStatus, + pub added: Option, + pub removed: Option, + pub binary: bool, +} + +/// The paths one commit touched, against its first parent. +/// +/// Two commands rather than one, because git will take `--numstat` and +/// `--name-status` together and then quietly drop the numstat half — measured +/// on 2.50.1, where the combined `-z` stream comes back as pure name-status. +/// So they are run separately and joined on the path. +/// +/// `log -1 --first-parent`, *not* `diff-tree -m --first-parent`: `diff-tree` +/// does not honour `--first-parent` as a narrowing of a merge. On the same git +/// it emits one diff per parent and concatenates them, so a two-parent merge +/// comes back with a file list twice as long as the merge really is. `log` is +/// also exactly how [`DiffSource::Commit`](super::diff::DiffSource) walks the +/// patch, which is what makes this list and the overlay's cards agree file for +/// file — and it needs no `--root`, because `log` shows a root commit's +/// contents as additions without being asked. +pub fn commit_files(host: &dyn Host, root: &Path, rev: &str) -> Option> { + let numstat = commit_diff(host, root, rev, "--numstat")?; + let name_status = commit_diff(host, root, rev, "--name-status")?; + Some(join_commit_files(&numstat, &name_status)) +} + +fn commit_diff(host: &dyn Host, root: &Path, rev: &str, what: &str) -> Option> { + if !is_rev(rev) { + return None; + } + let args = [ + "-c", + "log.showSignature=false", + // Without it a non-ASCII path comes back wrapped in quotes with its + // bytes spelled as C octal escapes, and nothing here decodes those. + "-c", + "core.quotePath=false", + "log", + "-1", + "--format=", + "--first-parent", + "--no-color", + "-z", + what, + "--find-renames", + rev, + ]; + let out = host.git(root, &args).ok()?; + out.success().then_some(out.stdout) +} + +/// A rev the caller made up is still a rev git will be handed, so anything +/// that could be read as an option is refused before it gets there. +fn is_rev(rev: &str) -> bool { + !rev.is_empty() && !rev.starts_with('-') && !rev.contains(|c: char| c.is_control()) +} + +fn records(stdout: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut on_record = |record: &[u8]| out.push(String::from_utf8_lossy(record).into_owned()); + let mut split = RecordSplitter::new(0); + split.push(stdout, &mut on_record); + split.finish(&mut on_record); + out +} + +/// `-z --numstat`: `\t\t\0` per file — except for a +/// rename or a copy, where the third field is *empty* and the old and the new +/// path follow as two records of their own. A binary file reports `-\t-`. +fn parse_numstat(stdout: &[u8]) -> HashMap, Option, bool)> { + let mut out = HashMap::new(); + let records = records(stdout); + let mut at = 0usize; + while at < records.len() && out.len() < MAX_COMMIT_FILES { + let record = &records[at]; + at += 1; + let mut fields = record.splitn(3, '\t'); + let (Some(added), Some(removed), Some(path)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let binary = added == "-" && removed == "-"; + let counts = (added.parse::().ok(), removed.parse::().ok()); + let path = if path.is_empty() { + let new = records.get(at + 1).cloned(); + at += 2; + match new { + Some(new) => new, + // Truncated mid-rename. Nothing else can be read from here. + None => break, + } + } else { + path.to_string() + }; + out.insert(path, (counts.0, counts.1, binary)); + } + out +} + +/// `-z --name-status`: `\0\0`, and `R\0\0\0` +/// for the two statuses that name two paths. +fn parse_name_status(stdout: &[u8]) -> Vec<(String, Option, FileStatus)> { + let mut out = Vec::new(); + let records = records(stdout); + let mut at = 0usize; + while at < records.len() && out.len() < MAX_COMMIT_FILES { + let code = records[at].trim().to_string(); + at += 1; + let two_paths = matches!(code.as_bytes().first(), Some(b'R' | b'C')); + let taken = 1 + usize::from(two_paths); + let Some(paths) = records.get(at..at + taken) else { + break; + }; + at += taken; + let Some(status) = file_status(&code) else { + continue; + }; + match paths { + [path] => out.push((path.clone(), None, status)), + [old, new] => out.push((new.clone(), Some(old.clone()), status)), + _ => {} + } + } + out +} + +fn file_status(code: &str) -> Option { + match code.as_bytes().first()? { + b'A' => Some(FileStatus::Added), + b'M' => Some(FileStatus::Modified), + b'D' => Some(FileStatus::Deleted), + b'R' => Some(FileStatus::Renamed), + b'C' => Some(FileStatus::Copied), + b'T' => Some(FileStatus::TypeChanged), + // `X` is git's own "unknown"; `B` only appears under + // `--break-rewrites`, which nothing here passes. + b'U' => Some(FileStatus::Unmerged), + _ => None, + } +} + +/// Joins the two streams on the path. +/// +/// `--name-status` is the spine: it carries the letter every row is drawn +/// from, and it is in git's own order. `--numstat` only contributes counts, so +/// losing it costs the numbers and nothing else. Losing the other way round is +/// worse — a path with counts and no letter would vanish — so anything left +/// over is appended rather than dropped. +pub fn join_commit_files(numstat: &[u8], name_status: &[u8]) -> Vec { + let mut counts = parse_numstat(numstat); + let named = parse_name_status(name_status); + let mut out: Vec = Vec::with_capacity(named.len()); + for (path, orig_path, status) in named { + let (added, removed, binary) = counts.remove(&path).unwrap_or((None, None, false)); + out.push(CommitFile { + path, + orig_path, + status, + added, + removed, + binary, + }); + } + let mut leftover: Vec<_> = counts.into_iter().collect(); + // A `HashMap` has no order to preserve, and a file list that reshuffles + // itself between two reads of the same commit would be worse than a + // list that is merely not in git's order. + leftover.sort_by(|a, b| a.0.cmp(&b.0)); + for (path, (added, removed, binary)) in leftover { + if out.len() >= MAX_COMMIT_FILES { + break; + } + out.push(CommitFile { + path, + orig_path: None, + status: FileStatus::Modified, + added, + removed, + binary, + }); + } + out +} + /// Loads the newest `count` commits of `scope` and lays them out. /// /// Paging is a bigger `-n`, never `--skip`. `--skip=M` walks and discards M @@ -1282,6 +1551,195 @@ mod tests { assert_eq!(by_oid[SHA_C][0].short, "origin/dev"); } + #[test] + fn a_branch_keeps_the_upstream_it_tracks() { + let lines = [ + format!( + "{SHA_A}\x1frefs/heads/main\x1fmain\x1frefs/remotes/origin/main\x1f*\x1fcommit\x1f" + ), + // A branch nobody has published tracks nothing, and an empty + // `%(upstream)` has to stay `None` rather than become `Some("")`. + format!("{SHA_B}\x1frefs/heads/local-only\x1flocal-only\x1f\x1f \x1fcommit\x1f"), + format!("{SHA_C}\x1frefs/tags/v9\x1fv9\x1f\x1f \x1fcommit\x1f"), + ]; + let by_oid = parse_refs(lines.join("\n").as_bytes()); + + assert_eq!( + by_oid[SHA_A][0].upstream.as_deref(), + Some("refs/remotes/origin/main") + ); + assert_eq!(by_oid[SHA_B][0].upstream, None); + assert_eq!(by_oid[SHA_C][0].upstream, None); + // `%D` cannot carry an upstream at all, so a decoration parsed out of + // a log record must not claim one. + let logged = parse_log(one(SHA_A, "", "refs/heads/main", "s", "").as_bytes()); + assert_eq!(logged[0].refs[0].upstream, None); + } + + #[test] + fn branch_names_come_back_one_per_line() { + let out = b"main\nfeature/a\n\n spaced \n"; + assert_eq!( + parse_branch_names(out), + ["main", "feature/a", "spaced"], + "blank lines are not branches, and git pads nothing" + ); + assert!(parse_branch_names(b"").is_empty()); + let many: String = (0..MAX_REFS + 50) + .map(|i| format!("b{i}\n")) + .collect::>() + .concat(); + assert_eq!(parse_branch_names(many.as_bytes()).len(), MAX_REFS); + } + + /// Every shape `-z` can produce, from the streams git actually emits — + /// each of these was captured from git 2.50.1 rather than guessed. + #[test] + fn a_commits_file_list_joins_the_two_z_streams() { + // A rename, a path with a space, a path outside ASCII, and a binary. + let numstat = b"1\t0\tbin.dat\x000\t0\t\x00a.txt\x00renamed.txt\x001\t0\twith space.txt\x002\t3\t\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.txt\x00"; + let name_status = b"A\x00bin.dat\x00R100\x00a.txt\x00renamed.txt\x00A\x00with space.txt\x00M\x00\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.txt\x00"; + let files = join_commit_files(numstat, name_status); + + assert_eq!(files.len(), 4, "{files:?}"); + assert_eq!(files[0].path, "bin.dat"); + assert_eq!(files[0].status, FileStatus::Added); + assert_eq!((files[0].added, files[0].removed), (Some(1), Some(0))); + + // The rename: `--numstat` spells it as an empty third field followed + // by two records of its own, and the counts belong to the new name. + assert_eq!(files[1].path, "renamed.txt"); + assert_eq!(files[1].orig_path.as_deref(), Some("a.txt")); + assert_eq!(files[1].status, FileStatus::Renamed); + assert_eq!((files[1].added, files[1].removed), (Some(0), Some(0))); + + assert_eq!( + files[2].path, "with space.txt", + "a space is not a separator" + ); + assert_eq!(files[3].path, "中文名.txt"); + assert_eq!((files[3].added, files[3].removed), (Some(2), Some(3))); + assert!(files.iter().all(|f| !f.binary)); + } + + #[test] + fn a_binary_file_reports_no_counts_rather_than_zero() { + let files = join_commit_files(b"-\t-\tbin2.dat\x00", b"A\x00bin2.dat\x00"); + assert_eq!(files.len(), 1); + assert!(files[0].binary); + assert_eq!( + (files[0].added, files[0].removed), + (None, None), + "`0 0` is a real answer and `-\t-` is not, so they must not read alike" + ); + // A pure rename really does change nothing, and says so. + let renamed = join_commit_files(b"0\t0\t\x00a\x00b\x00", b"R100\x00a\x00b\x00"); + assert!(!renamed[0].binary); + assert_eq!((renamed[0].added, renamed[0].removed), (Some(0), Some(0))); + } + + #[test] + fn one_stream_going_missing_degrades_instead_of_emptying_the_list() { + // No counts: every row still knows what happened to it. + let no_numstat = join_commit_files(b"", b"M\x00a.txt\x00D\x00b.txt\x00"); + assert_eq!(no_numstat.len(), 2); + assert_eq!(no_numstat[1].status, FileStatus::Deleted); + assert!(no_numstat.iter().all(|f| f.added.is_none())); + + // No letters: the paths are the more important half, so they are kept + // and given the one status that claims the least. + let no_names = join_commit_files(b"1\t2\tb.txt\x003\t4\ta.txt\x00", b""); + assert_eq!( + no_names.iter().map(|f| f.path.as_str()).collect::>(), + ["a.txt", "b.txt"], + "with no order to inherit the leftovers are sorted, not shuffled" + ); + assert!(no_names.iter().all(|f| f.status == FileStatus::Modified)); + assert_eq!((no_names[0].added, no_names[0].removed), (Some(3), Some(4))); + + assert!(join_commit_files(b"", b"").is_empty()); + } + + #[test] + fn a_truncated_or_unknown_record_is_dropped_rather_than_shifting_the_parse() { + // A status letter with no path behind it ends the read; anything + // already parsed still stands. + let cut = join_commit_files(b"", b"M\x00a.txt\x00D\x00"); + assert_eq!(cut.len(), 1); + assert_eq!(cut[0].path, "a.txt"); + + // `X` is git's own "something went wrong". Its path is consumed so the + // records after it stay aligned. + let unknown = join_commit_files(b"", b"X\x00weird\x00A\x00good.txt\x00"); + assert_eq!(unknown.len(), 1); + assert_eq!(unknown[0].path, "good.txt"); + + // A rename cut off after its old name leaves nothing to attach to. + assert!(join_commit_files(b"0\t0\t\x00a\x00", b"R100\x00a\x00").is_empty()); + } + + #[test] + fn a_file_list_is_capped_without_losing_its_first_rows() { + let mut numstat = Vec::new(); + let mut name_status = Vec::new(); + for i in 0..MAX_COMMIT_FILES + 20 { + numstat.extend_from_slice(format!("1\t0\tf{i:05}.rs\0").as_bytes()); + name_status.extend_from_slice(format!("A\0f{i:05}.rs\0").as_bytes()); + } + let files = join_commit_files(&numstat, &name_status); + assert_eq!(files.len(), MAX_COMMIT_FILES); + assert_eq!(files[0].path, "f00000.rs"); + } + + #[test] + fn a_rev_that_could_be_read_as_an_option_never_reaches_git() { + assert!(is_rev("HEAD")); + assert!(is_rev(SHA_A)); + assert!(is_rev("v1.0^{commit}")); + assert!(!is_rev("")); + assert!(!is_rev("--upload-pack=touch /tmp/pwned")); + assert!(!is_rev("-n")); + assert!(!is_rev("HEAD\nrm -rf")); + } + + #[test] + fn this_repo_answers_for_one_commit_and_its_files() { + let host = crate::host::local::LocalHost::new(); + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + // A source tarball is a legitimate place to run the tests from. + let Some(page) = load_page(&*host, here, &GraphScope::Head, 2) else { + return; + }; + let Some(head) = page.commits.first() else { + return; + }; + + let shown = load_commit(&*host, here, &head.oid).expect("HEAD is a commit"); + assert_eq!(shown.oid, head.oid); + assert_eq!(shown.summary, head.summary, "the two formats are the same"); + assert_eq!(shown.parents.as_slice(), head.parents.as_slice()); + assert_eq!(shown.author.at, head.author.at); + assert_eq!(load_commit(&*host, here, "-n"), None); + + let files = commit_files(&*host, here, &head.oid).expect("HEAD touched something"); + assert!(!files.is_empty(), "no commit in this repo is empty"); + assert!( + files.iter().all(|f| !f.path.is_empty()), + "an empty path means the join lost a record: {files:?}" + ); + // The whole reason the two commands are run separately. + assert!( + files + .iter() + .any(|f| f.added.is_some() || f.removed.is_some() || f.binary), + "not one row got its counts: {files:?}" + ); + + let branches = local_branches(&*host, here); + assert!(!branches.is_empty(), "this checkout is on a branch"); + assert!(branches.iter().all(|b| !b.starts_with("refs/heads/"))); + } + #[test] fn this_repo_lays_out_one_row_per_commit() { let host = crate::host::local::LocalHost::new(); diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 3dc13f15..ab80d3e3 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -12,8 +12,8 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f use crate::core::config::{Config, DiffViewMode}; use crate::core::git::status::DecoStatus; use crate::terminal::git_diff::{ - self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffSource, DiffStats, FileDiff, FileStatus, LineKind, - MAX_RENDERED_FILES, Truncation, + self, AUTO_COLLAPSE_LINES, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff, + FileStatus, LineKind, MAX_RENDERED_FILES, Truncation, }; use crate::ui::app::Tty7App; use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; @@ -21,6 +21,7 @@ use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::info_chip; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; +use crate::ui::scm::path::relative_time; use crate::ui::scm::status::{status_color, status_glyph}; pub(crate) enum DiffLoad { @@ -444,6 +445,27 @@ impl Tty7App { &mono, )) }) + // The subject takes the slack the spacer below would otherwise + // have, which is why that one is skipped when a label is present: + // two `flex_1` siblings split the line in half and the subject + // would truncate with empty space beside it. + .when_some(subject.label.as_ref(), |bar, label| { + bar.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .child(SharedString::from(label.subject.clone())), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label_byline(label, now_unix())), + ) + }) .when_some(focused_name(overlay), |bar, name| { bar.child( div().occlude().flex_shrink_0().child( @@ -523,7 +545,7 @@ impl Tty7App { ) }, ) - .child(div().flex_1()) + .when(subject.label.is_none(), |bar| bar.child(div().flex_1())) .child(div().occlude().flex_shrink_0().child({ let sf = cx.global::().window; let selected = usize::from(view_mode(cx) == DiffViewMode::Unified); @@ -1062,7 +1084,7 @@ fn unified_marker(kind: LineKind) -> &'static str { /// `Copied` and `TypeChanged` have no decoration of their own — porcelain v2's /// index folds them the same way — so they take the nearest one rather than /// inventing a `C` and a `T` that appear in the overlay and nowhere else. -fn deco_status(status: FileStatus) -> DecoStatus { +pub(crate) fn deco_status(status: FileStatus) -> DecoStatus { match status { FileStatus::Added => DecoStatus::Added, FileStatus::Modified => DecoStatus::Modified, @@ -1089,6 +1111,10 @@ struct SourceSubject { /// Set only where the branch name alone would be ambiguous. chip: Option<&'static str>, is_rev: bool, + /// What the commit is *about*, where whoever opened it knew. An object id + /// is an address, not a name, and a header with nothing but eight hex + /// digits leaves the reader to remember which commit that was. + label: Option, } fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { @@ -1097,6 +1123,7 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { text: branch.clone(), chip, is_rev: false, + label: None, }; match source { // Worktree and Head are both "the branch, right now"; the header for @@ -1105,21 +1132,45 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { // Staged is the branch too, but a patch that does not match the files // on disk — without the chip it is indistinguishable from the above. DiffSource::Staged => branch_of(Some("STAGED")), - DiffSource::Commit { rev } => SourceSubject { + DiffSource::Commit { rev, label } => SourceSubject { icon: "icons/git-commit.svg", text: short_rev(rev), chip: None, is_rev: true, + // An empty subject is no more use than no label at all, and a + // `Default::default()` that leaked through would render as one. + label: label.clone().filter(|l| !l.subject.is_empty()), }, DiffSource::Range { base, head } => SourceSubject { icon: "icons/git-commit.svg", text: format!("{}…{}", short_rev(base), short_rev(head)), chip: None, is_rev: true, + label: None, }, } } +/// `Ada · 2h`, the byline under a commit's subject. +/// +/// One string rather than two elements: the separator has to disappear along +/// with whichever half is missing, and a `when_some` chain around a middle dot +/// says less than this does. +fn label_byline(label: &CommitLabel, now: i64) -> String { + let when = (label.at > 0).then(|| relative_time(now, label.at)); + match (label.author.trim(), when) { + ("", Some(when)) => when, + (author, Some(when)) => format!("{author} · {when}"), + (author, None) => author.to_string(), + } +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + /// Object ids get cut to eight characters; anything else is already a name a /// person chose, and cutting `origin/main` in half would only hide which it is. fn short_rev(rev: &str) -> String { @@ -1191,15 +1242,19 @@ fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String { /// The de-duplication sets on `Tty7App` are keyed by `(HostId, PathBuf)`, so /// the source rides along inside the path: two sources over one directory are -/// two independent probes and must not cancel one another. `Debug` is what -/// makes the tag unique — it carries the rev of a `Commit` and both ends of a -/// `Range` — and the separator is a byte no path contains. +/// two independent probes and must not cancel one another. +/// +/// `DiffSource::tag` rather than `Debug`, which is what this used to be built +/// from. `Debug` prints a commit's label too, so the same commit opened with a +/// subject in hand and without one would have been two keys and two probes for +/// one patch — the same split `DiffSource`'s own `PartialEq` is written to +/// avoid. The separator is a byte no path contains. fn probe_key( host: crate::ui::host_ops::HostId, cwd: &Path, source: &DiffSource, ) -> (crate::ui::host_ops::HostId, PathBuf) { - let mut tagged = std::ffi::OsString::from(format!("{source:?}\u{1}")); + let mut tagged = std::ffi::OsString::from(format!("{}\u{1}", source.tag())); tagged.push(cwd.as_os_str()); (host, PathBuf::from(tagged)) } @@ -1227,8 +1282,8 @@ mod tests { assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Staged)); assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Head)); assert_ne!( - probe_key(host, cwd, &DiffSource::Commit { rev: "a".into() }), - probe_key(host, cwd, &DiffSource::Commit { rev: "b".into() }), + probe_key(host, cwd, &DiffSource::commit("a")), + probe_key(host, cwd, &DiffSource::commit("b")), "two commits are two probes" ); assert_eq!(worktree, probe_key(host, cwd, &DiffSource::Worktree)); @@ -1236,6 +1291,24 @@ mod tests { worktree, probe_key(host, Path::new("/other"), &DiffSource::Worktree) ); + // …and one commit is one probe however much is known about it. Built + // from `Debug`, as this key once was, the labelled one would have been + // a second in-flight probe for a patch already being read. + assert_eq!( + probe_key(host, cwd, &DiffSource::commit("a")), + probe_key( + host, + cwd, + &DiffSource::Commit { + rev: "a".into(), + label: Some(CommitLabel { + subject: "s".into(), + author: "Ada".into(), + at: 1, + }), + } + ) + ); } #[test] @@ -1307,9 +1380,7 @@ mod tests { ); let commit = source_subject( - &DiffSource::Commit { - rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), - }, + &DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), branch(), ); assert_eq!(commit.icon, COMMIT_ICON); @@ -1327,6 +1398,83 @@ mod tests { assert_eq!(range.text, "main…feature"); } + #[test] + fn a_labelled_commit_says_what_it_was_about() { + let label = CommitLabel { + subject: "fix(scm): stop the panel asking twice".into(), + author: "Ada".into(), + at: 1_786_255_391, + }; + let with = source_subject( + &DiffSource::Commit { + rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + label: Some(label.clone()), + }, + "main".to_string(), + ); + assert_eq!(with.text, "3f2a1b9c", "the sha is still the identifier"); + assert_eq!( + with.label.as_ref().map(|l| l.subject.as_str()), + Some(label.subject.as_str()) + ); + + // Nothing else grows a subject line, least of all a working-tree + // patch, whose "subject" would be a branch name repeated. + assert!( + source_subject(&DiffSource::Worktree, "main".into()) + .label + .is_none() + ); + assert!( + source_subject(&DiffSource::Head, "main".into()) + .label + .is_none() + ); + assert!( + source_subject(&DiffSource::commit("deadbeef"), "main".into()) + .label + .is_none(), + "a commit nobody has read yet has nothing to say" + ); + // A default-constructed label is indistinguishable from none, and must + // not paint an empty row where the subject would go. + let empty = source_subject( + &DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel::default()), + }, + "main".into(), + ); + assert!(empty.label.is_none()); + } + + #[test] + fn the_byline_drops_the_separator_along_with_the_half_it_joined() { + let now = 1_786_255_391 + 7200; + let full = CommitLabel { + subject: "s".into(), + author: "Ada".into(), + at: 1_786_255_391, + }; + assert_eq!(label_byline(&full, now), "Ada · 2h"); + assert_eq!( + label_byline( + &CommitLabel { + author: String::new(), + ..full.clone() + }, + now + ), + "2h", + "a commit with no author is not `· 2h`" + ); + assert_eq!( + label_byline(&CommitLabel { at: 0, ..full }, now), + "Ada", + "and a timestamp that would not parse is not `Ada · 56y`" + ); + } + const BRANCH_ICON: &str = "icons/git-branch.svg"; const COMMIT_ICON: &str = "icons/git-commit.svg"; @@ -1868,8 +2016,14 @@ mod overlay_gpui_tests { DiffSource::Worktree, DiffSource::Staged, DiffSource::Head, + DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), DiffSource::Commit { rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + label: Some(CommitLabel { + subject: "fix(scm): read a commit's own header".into(), + author: "Ada".into(), + at: 1_786_255_391, + }), }, DiffSource::Range { base: "main".into(), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6e1c8200..5317e04e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -857,6 +857,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmResetToCommit => "Reset to Commit", L10nKey::ScmRefresh => "Refresh", L10nKey::ScmBackToChanges => "Back", + L10nKey::ScmCommitParents => "Parents", + L10nKey::ScmShowMore => "Show more", + L10nKey::ScmShowLess => "Show less", + L10nKey::ScmCommitNotFound => "This commit is not in this repository.", L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", L10nKey::ScmOpenChanges => "Open Changes", L10nKey::ScmDiscardAllConfirm => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4a782126..7b1dbf32 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -907,6 +907,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "このコミットにリセット", L10nKey::ScmRefresh => "更新", L10nKey::ScmBackToChanges => "戻る", + L10nKey::ScmCommitParents => "親コミット", + L10nKey::ScmShowMore => "続きを表示", + L10nKey::ScmShowLess => "折りたたむ", + L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。", L10nKey::ScmTooManyChanges => { "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 81cf061a..95df2ccd 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -683,6 +683,13 @@ pub enum L10nKey { ScmResetToCommit, ScmRefresh, ScmBackToChanges, + /// Header over the parent links in the commit detail view. + ScmCommitParents, + /// The fold toggle under a long commit body. + ScmShowMore, + ScmShowLess, + /// The detail view asked git for a commit and git did not have one. + ScmCommitNotFound, /// Shown when the working tree has more changes than the status parser /// keeps. The list is still useful; the count at the top would otherwise /// be a lie. @@ -1135,12 +1142,10 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmGraphAllBranches, L10nKey::ScmGraphEmpty, L10nKey::ScmCommitDetailTitle, - L10nKey::ScmCopyCommitSha, L10nKey::ScmCherryPick, L10nKey::ScmRevertCommit, L10nKey::ScmResetToCommit, L10nKey::ScmRefresh, - L10nKey::ScmBackToChanges, L10nKey::ScmTooManyChanges, L10nKey::ScmOpenChanges, L10nKey::ScmDiscardAllConfirm, @@ -1152,7 +1157,6 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmOpBisect, L10nKey::ScmOpAm, L10nKey::ScmSwitchRepository, - L10nKey::ScmFilesChanged, L10nKey::DiffViewSplit, L10nKey::DiffViewUnified, ]; diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e23182a7..5d662d8d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -830,6 +830,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "重置到该提交", L10nKey::ScmRefresh => "刷新", L10nKey::ScmBackToChanges => "返回", + L10nKey::ScmCommitParents => "父提交", + L10nKey::ScmShowMore => "展开", + L10nKey::ScmShowLess => "收起", + L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", L10nKey::ScmOpenChanges => "查看改动", L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。", diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index ea6c972a..323f8939 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -8,20 +8,891 @@ //! //! The patch itself still goes to the full-screen overlay. 260px is not a //! place to read a diff. +//! +//! This is also where the panel pays back what the graph gave up. A history +//! row has about 26 characters beside its lanes and this repository's subjects +//! run to a median of 64, so the graph shows shape and this shows text: the +//! whole subject, the body, every ref, the parents, and the files. -use gpui::{AnyElement, Context}; +use std::sync::Arc; -use crate::ui::app::Tty7App; -use crate::ui::scm::state::CommitDetailView; +use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use tty7_core::core::git::diff::CommitLabel; +use tty7_core::core::git::log::{Commit, CommitFile, RefKind}; +use tty7_core::core::git::status::DecoStatus; + +use crate::terminal::git_diff::DiffSource; +use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::right_panel::{git_badge, info_chip}; +use crate::ui::scm::path::{relative_time, split_display_path}; +use crate::ui::scm::state::{CommitDetailView, RepoKey}; +use crate::ui::scm::status::{status_color, status_glyph}; + +/// A file row, the same height as the working tree's, and inset the same way. +/// The two lists sit in one column and have to read as one grid. +const ROW_H: f32 = 24.; +const ROW_INSET: f32 = 4.; + +/// How much of the body is shown before it folds. Four lines is a paragraph; +/// past that it is a changelog, and the file list is what the reader came for. +const BODY_LINES: usize = 4; + +/// And how much of the subject, which wraps rather than folding. Three lines +/// of 12px in 260px is around 90 characters — longer than every subject in +/// this repository but a handful, and a cap for the ones that are a paragraph. +const SUBJECT_LINES: usize = 3; impl Tty7App { + /// Show one commit, replacing the working tree in the panel body. + /// + /// `seed` is the commit the caller already has. The graph's page carries + /// every field this view renders, so a click on a row hands its own + /// [`Commit`] over and no `git show` is run at all; a parent link, or + /// anything else reaching a commit outside that window, passes `None` and + /// pays for the read. + pub(crate) fn open_commit_detail( + &mut self, + repo: RepoKey, + oid: String, + seed: Option, + cx: &mut Context, + ) { + self.scm.detail = Some(CommitDetailView::new(repo, oid, seed)); + cx.notify(); + } + + pub(crate) fn close_commit_detail(&mut self, cx: &mut Context) { + if self.scm.detail.take().is_some() { + cx.notify(); + } + } + /// The commit detail body, shown in place of the file groups. pub(crate) fn render_commit_detail( &mut self, - _detail: &CommitDetailView, - _window: &mut gpui::Window, - _cx: &mut Context, + detail: &CommitDetailView, + _window: &mut Window, + cx: &mut Context, ) -> Option { - None + // The detail names its own repository, and the panel may since have + // followed the active pane somewhere else. A commit from a repository + // nobody is looking at any more is not a second-level view of + // anything, so it goes rather than sitting on top of the wrong tree. + if self.scm.active_repo() != Some(&detail.repo) { + self.scm.detail = None; + return None; + } + self.load_commit_detail(detail, cx); + + let mono = cx.theme().mono_font_family.clone(); + let muted = cx.theme().muted_foreground; + // Each section insets itself rather than sharing one on the column: + // `panel_subtitle` applies `CONTENT_INSET` of its own, and an outer + // inset would push it eight pixels right of the rows beneath it. + let mut body = v_flex() + .py(px(2.)) + .child(self.detail_header_row(detail, &mono, cx)); + + match detail.commit.as_deref() { + Some(commit) => { + body = body + .child(self.detail_message(detail, commit, cx)) + .children(self.detail_refs(commit, &mono, cx)) + .children(self.detail_parents(detail, commit, &mono, cx)) + .child(self.detail_files(detail, commit, &mono, cx)); + } + // Nothing came back. `loaded` is what tells "still reading" apart + // from "git has no such commit here" — without it a bad oid would + // read as a spinner that never stops. + None => { + body = body.child( + div() + .px(px(CONTENT_INSET)) + .py(px(4.)) + .text_size(px(12.)) + .text_color(muted) + .child(if detail.loaded { + t(L10nKey::ScmCommitNotFound) + } else { + t(L10nKey::PanelLoading) + }), + ); + } + } + Some(body.into_any_element()) + } + + /// Read the commit and its file list, once. + /// + /// Runs from `render`, so it has to be idempotent in the strongest sense: + /// the panel is redrawn on every status change and a second dispatch would + /// mean a `git show` per frame. `loading` covers the window while a read + /// is out and `loaded` covers every frame after it lands, including the + /// ones where it landed with nothing. + fn load_commit_detail(&mut self, detail: &CommitDetailView, cx: &mut Context) { + if detail.loading || detail.loaded { + return; + } + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, detail.repo.host) else { + return; + }; + if let Some(open) = self.scm.detail.as_mut() { + open.loading = true; + } + let root = detail.repo.root.clone(); + let oid = detail.oid.clone(); + // A seeded view already has its metadata and only wants the files, so + // the `show` is skipped rather than run for an answer we hold. + let seeded = detail.commit.is_some(); + let key = (detail.repo.clone(), detail.oid.clone()); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| { + use tty7_core::core::git::log; + let commit = (!seeded) + .then(|| log::load_commit(h, &root, &oid)) + .flatten(); + (commit, log::commit_files(h, &root, &oid)) + }, + move |this, (commit, files), cx| { + // The user may have gone back, or moved on to another commit, + // while the read was out. Landing it anywhere but on the view + // that asked would show one commit's files under another's + // message. + let Some(open) = this + .scm + .detail + .as_mut() + .filter(|d| (d.repo.clone(), d.oid.clone()) == key) + else { + return; + }; + open.loading = false; + open.loaded = true; + if let Some(commit) = commit { + open.commit = Some(Arc::new(commit)); + } + open.files = Some(Arc::new(files.unwrap_or_default())); + cx.notify(); + }, + ); + } + + /// The way back, and the object id. + /// + /// The back affordance belongs in `panel_title`'s trailing slot, where the + /// diff overlay puts its own. It is here instead because the title is + /// rendered by the panel and this function only produces the body — see + /// the note in `render_panel_scm`. Being the first row of the body it + /// scrolls with the content, which is the one thing lost by the move. + fn detail_header_row( + &self, + detail: &CommitDetailView, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let oid = detail.oid.clone(); + h_flex() + .items_center() + .gap(px(4.)) + .h(px(ROW_H)) + .px(px(CONTENT_INSET - ROW_INSET)) + .child( + h_flex() + .id("scm-detail-back") + .items_center() + .gap(px(2.)) + .px(px(4.)) + .py(px(1.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .on_click(cx.listener(|this, _, _window, cx| this.close_commit_detail(cx))) + .child( + Icon::new(IconName::ChevronLeft) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child(div().text_xs().child(t(L10nKey::ScmBackToChanges))), + ) + .child(div().flex_1().min_w_0()) + .child( + div() + .id("scm-detail-sha") + .flex_none() + .px(px(4.)) + .py(px(1.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .text_size(px(13.)) + .font_family(mono.clone()) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::ScmCopyCommitSha)) + .build(window, cx) + }) + .on_click(move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(oid.clone())); + }) + .child(short_oid(&detail.oid).to_string()), + ) + .into_any_element() + } + + /// Subject, byline, body. + fn detail_message( + &self, + detail: &CommitDetailView, + commit: &Commit, + cx: &mut Context, + ) -> AnyElement { + let body = commit.body.trim(); + let lines = body.lines().count(); + let folded = !detail.body_expanded && lines > BODY_LINES; + v_flex() + .px(px(CONTENT_INSET)) + .pb(px(4.)) + .gap(px(3.)) + .child( + // Wrapping, not truncating: this view exists because the + // graph row could only show the first 26 characters. + div() + .text_size(px(12.)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_clamp(SUBJECT_LINES) + .child(SharedString::from(commit.summary.clone())), + ) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(byline(commit, now_unix())), + ) + .when(!body.is_empty(), |this| { + this.child( + div() + .pt(px(2.)) + .text_size(px(11.5)) + .text_color(cx.theme().muted_foreground) + .when(folded, |d| d.line_clamp(BODY_LINES)) + .child(SharedString::from(body.to_string())), + ) + .when(lines > BODY_LINES, |this| { + this.child( + div() + .id("scm-detail-body-fold") + .w_full() + .py(px(1.)) + .cursor_pointer() + .text_size(px(11.)) + .text_color(cx.theme().info) + .on_click(cx.listener(|this, _, _window, cx| { + if let Some(open) = this.scm.detail.as_mut() { + open.body_expanded = !open.body_expanded; + cx.notify(); + } + })) + .child(t(if folded { + L10nKey::ScmShowMore + } else { + L10nKey::ScmShowLess + })), + ) + }) + }) + .into_any_element() + } + + /// Every ref pointing here, wrapped over as many lines as it takes. + /// + /// The graph row shows one chip and a `+N`; there is no reason to hide any + /// of them once there is a whole column to put them in. + fn detail_refs( + &self, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> Option { + if commit.refs.is_empty() { + return None; + } + let theme = cx.theme(); + let (accent, warning, fg, muted) = ( + theme.accent, + theme.warning, + theme.foreground, + theme.muted_foreground, + ); + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .px(px(CONTENT_INSET)) + .pb(px(6.)); + for deco in &commit.refs { + // The same three colours the graph's chips use: a tag is yellow + // because a tag is yellow everywhere in git, HEAD is emphasised, + // and everything else is quiet. + let (bg, color) = match deco.kind { + RefKind::Tag => (warning.opacity(0.16), warning), + _ if deco.is_head => (accent.opacity(0.28), fg), + _ => (accent, muted), + }; + row = row.child(info_chip(&deco.short, bg, color, mono)); + } + Some(row.into_any_element()) + } + + /// The parents, as links. Following one is the only way to walk history + /// backwards from a commit the graph's window does not reach. + fn detail_parents( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> Option { + if commit.parents.is_empty() { + return None; + } + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap(px(6.)) + .px(px(CONTENT_INSET)) + .pb(px(4.)) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::ScmCommitParents)), + ); + for parent in &commit.parents { + let repo = detail.repo.clone(); + let oid = parent.clone(); + row = row.child( + div() + .id(SharedString::from(format!("scm-detail-parent-{parent}"))) + .px(px(3.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().info) + .on_click(cx.listener(move |this, _, _window, cx| { + // No seed: a parent is by definition one step past + // whatever the caller had in hand. + this.open_commit_detail(repo.clone(), oid.clone(), None, cx); + })) + .child(short_oid(parent).to_string()), + ); + } + Some(row.into_any_element()) + } + + fn detail_files( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let files = detail.files.clone().unwrap_or_default(); + let mut list = v_flex().child(self.panel_subtitle( + &t_plural(L10nKey::ScmFilesChanged, files.len(), &[]), + true, + None, + cx, + )); + if detail.files.is_none() { + return list + .child(self.detail_note(t(L10nKey::PanelLoading).to_string(), cx)) + .into_any_element(); + } + // The label rides along on the source so the overlay's header can say + // which commit it is showing, and it is deliberately not part of that + // source's identity — the same commit opened from here and from + // anywhere else has to stay one overlay. + let source = DiffSource::Commit { + rev: detail.oid.clone(), + label: Some(CommitLabel { + subject: commit.summary.clone(), + author: commit.author.name.clone(), + at: commit.author.at.unix, + }), + }; + // The rows sit in the working tree's own column: laid out one + // `ROW_INSET` short of `CONTENT_INSET` and padding themselves back + // out, so a hovered row's background is wider than its text. + let mut rows = v_flex().px(px(CONTENT_INSET - ROW_INSET)); + for file in files.iter() { + rows = rows.child(self.detail_file_row(detail, &source, file, mono, cx)); + } + list.child(rows).into_any_element() + } + + /// The working tree's file row, minus the hover buttons. + /// + /// A copy of `scm_file_row`, which is the wrong way round and known to be: + /// the two have to stay pixel-identical and nothing here enforces that. + /// They differ only in what they are built from — a `StatusEntry` against + /// a [`CommitFile`] — and in the buttons, so the shared version is a + /// function over `(letter, deco, path)` plus an optional trailing element. + fn detail_file_row( + &self, + detail: &CommitDetailView, + source: &DiffSource, + file: &CommitFile, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let deco = crate::ui::diff_overlay::deco_status(file.status); + let (name, dir) = split_display_path(&file.path); + let selected = self.diff_overlay_focus(detail.repo.host, &detail.repo.root) + == Some(file.path.as_str()); + + h_flex() + .id(SharedString::from(format!("scm-detail-file-{}", file.path))) + .items_center() + .gap(px(8.)) + .h(px(ROW_H)) + .px(px(ROW_INSET)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .when(selected, |s| s.bg(gpui::rgb(sf.selected))) + .on_click({ + let repo = detail.repo.clone(); + let source = source.clone(); + let path = file.path.clone(); + cx.listener(move |this, _, window, cx| { + // 260px cannot render a patch, so the file level is the + // full-screen overlay's job — the same one the working + // tree's rows open, pointed at a commit instead. + this.open_diff_overlay( + repo.host, + repo.root.clone(), + source.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + .child(git_badge(status_glyph(deco), status_color(deco, cx), mono)) + .child( + div() + .flex_none() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if deco == DecoStatus::Deleted { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .when(deco == DecoStatus::Deleted, |s| s.line_through()) + .child(name.to_string()), + ) + .when(!dir.is_empty(), |this| { + this.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(dir.to_string()), + ) + }) + .into_any_element() + } + + fn detail_note(&self, text: String, cx: &mut Context) -> AnyElement { + div() + .px(px(CONTENT_INSET)) + .py(px(3.)) + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(text) + .into_any_element() + } +} + +/// `Ada Lovelace · 2h`. Author, not committer: a rebase rewrites the second +/// one, and "who wrote this" is the question a reader is asking. +pub(crate) fn byline(commit: &Commit, now: i64) -> String { + let when = (commit.author.at.unix > 0).then(|| relative_time(now, commit.author.at.unix)); + match (commit.author.name.trim(), when) { + ("", Some(when)) => when, + (name, Some(when)) => format!("{name} · {when}"), + (name, None) => name.to_string(), + } +} + +/// Seven, which is what git itself prints and what the graph's rows use. +pub(crate) fn short_oid(oid: &str) -> &str { + &oid[..oid.len().min(7)] +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tty7_core::core::git::log::{OffsetTs, Signature}; + + fn commit(name: &str, at: i64) -> Commit { + Commit { + oid: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + parents: Default::default(), + author: Signature { + name: name.into(), + email: "ada@example.com".into(), + at: OffsetTs { + unix: at, + offset_minutes: 0, + }, + }, + committer: Signature { + name: "Grace".into(), + email: "grace@example.com".into(), + at: OffsetTs { + unix: at, + offset_minutes: 0, + }, + }, + summary: "s".into(), + body: String::new(), + refs: Vec::new(), + } + } + + #[test] + fn the_byline_drops_the_separator_along_with_the_half_it_joined() { + let now = 1_786_255_391 + 7200; + assert_eq!(byline(&commit("Ada", 1_786_255_391), now), "Ada · 2h"); + assert_eq!( + byline(&commit("", 1_786_255_391), now), + "2h", + "an unattributed commit is not `· 2h`" + ); + assert_eq!( + byline(&commit("Ada", 0), now), + "Ada", + "and a date that would not parse is not `Ada · 56y`" + ); + } + + #[test] + fn a_short_oid_is_the_seven_characters_git_itself_prints() { + assert_eq!( + short_oid("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), + "3f2a1b9" + ); + assert_eq!(short_oid("abc"), "abc", "a truncated oid is not padded"); + assert_eq!(short_oid(""), ""); + } +} + +/// The detail view against a real repository, drawn in a real window. +/// +/// Construction alone would prove very little: everything that can go wrong +/// here — a missing global, a theme token, a slice through the middle of a +/// character — goes wrong during layout and paint, so these arm the render +/// probe and insist something was actually drawn. +#[cfg(all(test, unix))] +mod detail_gpui_tests { + use super::*; + use crate::daemon::protocol::DaemonMsg; + use crate::ui::app::{render_probe, test_window}; + use crate::ui::host_ops::HostId; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use std::path::{Path, PathBuf}; + use tty7_core::core::config::RightPanelTab; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-detail-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(&dir).unwrap() + } + + fn git(root: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?} failed"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// Two commits: a root, then one that renames a file, adds a path with a + /// space in it and writes a body long enough to fold. + fn two_commit_repo(name: &str) -> PathBuf { + let root = scratch(name); + git(&root, &["init", "--quiet"]); + git(&root, &["config", "user.email", "ada@example.com"]); + git(&root, &["config", "user.name", "Ada"]); + std::fs::write(root.join("a.txt"), "one\n").unwrap(); + git(&root, &["add", "a.txt"]); + git(&root, &["commit", "-qm", "root commit"]); + std::fs::rename(root.join("a.txt"), root.join("renamed.txt")).unwrap(); + std::fs::write(root.join("with space.txt"), "two\n").unwrap(); + std::fs::write(root.join("中文名.txt"), "three\n").unwrap(); + git(&root, &["add", "-A"]); + git( + &root, + &[ + "commit", + "-qm", + "feat(detail): a subject long enough that the graph row could never have shown it", + "-m", + "one\ntwo\nthree\nfour\nfive\nsix", + ], + ); + root + } + + fn panel_on( + cx: &mut TestAppContext, + root: &Path, + ) -> ( + Entity, + VisualTestContext, + std::os::unix::net::UnixStream, + ) { + let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx); + DaemonMsg::Cwd(root.to_path_buf()) + .encode(&mut pane) + .expect("the pane's socket takes the cwd"); + app.update_in(&mut vcx, |app, _, cx| { + app.right_panel_visible = true; + app.right_panel_tab = RightPanelTab::Scm; + cx.notify(); + }); + let want = root.to_path_buf(); + settle(&app, &mut vcx, move |app, _| { + app.scm.repo.as_ref().is_some_and(|r| r.root == want) + }); + (app, vcx, pane) + } + + /// Pump frames until the panel has done what it was asked. The panel only + /// starts a read from `render`, so nothing here can be awaited directly. + fn settle( + app: &Entity, + vcx: &mut VisualTestContext, + done: impl Fn(&Tty7App, &gpui::App) -> bool, + ) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + app.update_in(vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + if app.update_in(vcx, |app, _, cx| done(app, cx)) { + return; + } + assert!( + std::time::Instant::now() < deadline, + "the panel never settled" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + fn paths(app: &Entity, vcx: &mut VisualTestContext) -> Vec { + app.update_in(vcx, |app, _, _| { + app.scm + .detail + .as_ref() + .and_then(|d| d.files.clone()) + .map(|files| files.iter().map(|f| f.path.clone()).collect()) + .unwrap_or_default() + }) + } + + #[gpui::test] + fn a_commit_detail_reads_its_own_files_and_draws_them(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("draws"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), None, cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + // Nothing was seeded, so the metadata came from `git show`. + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().expect("the detail is open"); + let commit = detail.commit.as_ref().expect("git resolved the commit"); + assert_eq!(commit.oid, head); + assert!(commit.summary.starts_with("feat(detail):")); + assert_eq!(commit.parents.len(), 1); + assert_eq!(commit.body.lines().count(), 6, "long enough to fold"); + }); + let mut listed = paths(&app, &mut vcx); + listed.sort(); + assert_eq!( + listed, + ["renamed.txt", "with space.txt", "中文名.txt"], + "the two -z streams joined into one list" + ); + + // A real frame, so layout and paint run over every row above. + render_probe::arm(10_000); + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + assert!( + render_probe::draws() > 0, + "nothing was drawn, so nothing was proved" + ); + + // Expanding the body is another branch of the same element. + app.update_in(&mut vcx, |app, _, cx| { + app.scm.detail.as_mut().unwrap().body_expanded = true; + cx.notify(); + }); + render_probe::arm(10_000); + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + assert!(render_probe::draws() > 0); + + // The read runs from `render`, which is the shape that has spun this + // panel before: a dispatch that did not record itself would ask git + // for the same commit again on the frame its own answer caused. + assert_eq!(draws_while_idle(&mut vcx), 0); + + let _ = std::fs::remove_dir_all(&root); + } + + /// Copied from `panel.rs`'s own idle tests: arm the probe, let every timer + /// the panel owns fire, and count the frames nobody asked for. + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { + render_probe::arm(200); + vcx.background_executor.run_until_parked(); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(3)); + vcx.background_executor.run_until_parked(); + render_probe::arm(200); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(9)); + vcx.background_executor.run_until_parked(); + render_probe::draws() + } + + #[gpui::test] + fn following_a_parent_swaps_the_commit_and_going_back_clears_it(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("parent"); + let head = git(&root, &["rev-parse", "HEAD"]); + let parent = git(&root, &["rev-parse", "HEAD^"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), None, cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + // What the parent link does: the same call with the other oid, and + // nothing carried over from the commit that was on screen. + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), parent.clone(), None, cx) + }); + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().unwrap(); + assert_eq!(detail.oid, parent); + assert!(detail.commit.is_none(), "the old commit did not linger"); + assert!(!detail.loaded); + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + assert_eq!( + paths(&app, &mut vcx), + ["a.txt"], + "a root commit's files are what it added, with no --root needed" + ); + + app.update_in(&mut vcx, |app, _, cx| app.close_commit_detail(cx)); + assert!(app.update_in(&mut vcx, |app, _, _| app.scm.detail.is_none())); + let _ = std::fs::remove_dir_all(&root); + } + + #[gpui::test] + fn a_seeded_detail_only_asks_for_the_files(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("seeded"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + + // What the graph hands over: a row it already holds. The subject is + // deliberately not the real one, so a `git show` behind our back would + // overwrite it and show up here. + let mut seed = tty7_core::core::git::log::load_commit( + &*tty7_core::host::local::LocalHost::new(), + &root, + &head, + ) + .expect("the scratch repo answers"); + seed.summary = "what the graph already knew".into(); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), Some(seed), cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().unwrap(); + assert_eq!( + detail.commit.as_ref().unwrap().summary, + "what the graph already knew", + "the seed was kept, so no second read of the same commit happened" + ); + assert_eq!(detail.files.as_ref().unwrap().len(), 3); + }); + let _ = std::fs::remove_dir_all(&root); + } + + /// A commit from a repository the panel has since walked away from is not + /// a second-level view of anything. + #[gpui::test] + fn a_detail_from_another_repository_gives_the_body_back(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("elsewhere"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + app.update_in(&mut vcx, |app, window, cx| { + let stranger = RepoKey { + host: HostId::LOCAL, + root: PathBuf::from("/no/such/tty7/repo"), + }; + app.open_commit_detail(stranger.clone(), head.clone(), None, cx); + let detail = app.scm.detail.clone().unwrap(); + assert!(app.render_commit_detail(&detail, window, cx).is_none()); + assert!(app.scm.detail.is_none(), "and it does not come back"); + }); + let _ = std::fs::remove_dir_all(&root); } } diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index ff6da7c7..04d7ba65 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,11 +4,10 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// What is left unused is what the graph and the commit detail view will call: -// `relative_time` has no row to date yet, and `status_rank` is the file tree's -// to use. Both allows come off with the step that wires them up. +// What is left unused is what the graph will call, plus `status_rank`, which +// is the file tree's to use. Both allows come off with the step that wires +// them up. pub(crate) mod actions; -#[allow(dead_code)] pub(crate) mod detail; #[allow(dead_code)] pub(crate) mod graph; diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 96880939..121fe514 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -12,9 +12,11 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::Arc; use gpui::Entity; use gpui_component::input::InputState; +use tty7_core::core::git::log::{Commit, CommitFile}; use tty7_core::core::git::status::HeadState; use crate::ui::host_ops::HostId; @@ -174,11 +176,49 @@ pub(crate) struct GraphState { /// touched. A file-level diff is not shown here — that opens the full-screen /// overlay, because 260px cannot render a diff and pretending otherwise would /// mean inventing a third kind of container. +/// +/// The two loaded halves are behind `Arc` because the panel clones this whole +/// struct once per frame — `render_panel_scm` cannot hand `render_commit_ +/// detail` a borrow of `self.scm` and a `&mut self` at once — and a commit +/// that touched a thousand files would otherwise deep-copy a thousand paths +/// every time anything on the panel redrew. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct CommitDetailView { pub(crate) repo: RepoKey, pub(crate) oid: String, + /// A read is out. Set before it is dispatched, so the render that runs in + /// between does not ask for a second one. pub(crate) loading: bool, + /// Whether a read has ever come back. With `loading` it is what stops the + /// view asking again forever after a commit git could not resolve: the + /// pair says "nothing is in flight and nothing is coming". + pub(crate) loaded: bool, + /// `None` until a read lands, and still `None` afterwards for a commit + /// that is not in this repository. + pub(crate) commit: Option>, + pub(crate) files: Option>>, + /// A long body starts folded — a merge from a bot can run to fifty lines, + /// and the file list is what the reader came for. + pub(crate) body_expanded: bool, +} + +impl CommitDetailView { + /// A commit the panel is about to show. `seed` is the row the graph + /// already has in hand, where the caller came from the graph: the page + /// carries every field a detail view needs, so handing it over is what + /// keeps the common path from running `git show` for a commit that is + /// literally on screen. + pub(crate) fn new(repo: RepoKey, oid: String, seed: Option) -> CommitDetailView { + CommitDetailView { + repo, + oid, + loading: false, + loaded: false, + commit: seed.map(Arc::new), + files: None, + body_expanded: false, + } + } } #[cfg(test)]