merge: the commit detail view

This commit is contained in:
l0ng-ai
2026-08-09 10:21:39 +08:00
10 changed files with 1709 additions and 45 deletions
+138 -12
View File
@@ -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<CommitLabel>,
},
/// `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<H: std::hash::Hasher>(&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<String>) -> 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<String> {
@@ -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<String> {
let req = DiffRequest {
source: DiffSource::Commit {
rev: rev(dir, spec),
},
source: DiffSource::commit(rev(dir, spec)),
..Default::default()
};
probe_diff(host, dir, &req)
+463 -5
View File
@@ -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<String>,
}
#[derive(Clone, PartialEq, Eq, Debug)]
@@ -496,6 +505,7 @@ fn parse_deco(text: &str) -> Vec<RefDeco> {
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<RefDeco> {
full: full.to_string(),
short: short.to_string(),
is_head,
upstream: None,
})
}
@@ -538,11 +549,10 @@ pub fn parse_refs(stdout: &[u8]) -> HashMap<Oid, Vec<RefDeco>> {
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<Oid, Vec<RefDeco>> {
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<Oid, Vec<RefDeco>>
}
}
/// 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<String> {
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> {
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<Commit> {
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<String>,
pub status: FileStatus,
pub added: Option<u32>,
pub removed: Option<u32>,
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<Vec<CommitFile>> {
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<Vec<u8>> {
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<String> {
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`: `<added>\t<removed>\t<path>\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<String, (Option<u32>, Option<u32>, 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::<u32>().ok(), removed.parse::<u32>().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`: `<status>\0<path>\0`, and `R<score>\0<old>\0<new>\0`
/// for the two statuses that name two paths.
fn parse_name_status(stdout: &[u8]) -> Vec<(String, Option<String>, 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<FileStatus> {
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<CommitFile> {
let mut counts = parse_numstat(numstat);
let named = parse_name_status(name_status);
let mut out: Vec<CommitFile> = 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::<Vec<_>>()
.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::<Vec<_>>(),
["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();
+168 -14
View File
@@ -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::<crate::ui::presets::Surfaces>().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<CommitLabel>,
}
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(),
+4
View File
@@ -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 => {
+4
View File
@@ -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} 件のみ表示しています。"
}
+7 -3
View File
@@ -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,
];
+4
View File
@@ -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 => "放弃本仓库的全部改动?此操作无法撤销。",
+878 -7
View File
@@ -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<Commit>,
cx: &mut Context<Self>,
) {
self.scm.detail = Some(CommitDetailView::new(repo, oid, seed));
cx.notify();
}
pub(crate) fn close_commit_detail(&mut self, cx: &mut Context<Self>) {
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<Self>,
detail: &CommitDetailView,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
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<Self>) {
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<Self>,
) -> 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<Self>,
) -> 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<Self>,
) -> Option<AnyElement> {
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<Self>,
) -> Option<AnyElement> {
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<Self>,
) -> 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<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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<Self>) -> 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<Tty7App>,
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<Tty7App>,
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<Tty7App>, vcx: &mut VisualTestContext) -> Vec<String> {
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);
}
}
+3 -4
View File
@@ -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;
+40
View File
@@ -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<Arc<Commit>>,
pub(crate) files: Option<Arc<Vec<CommitFile>>>,
/// 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<Commit>) -> CommitDetailView {
CommitDetailView {
repo,
oid,
loading: false,
loaded: false,
commit: seed.map(Arc::new),
files: None,
body_expanded: false,
}
}
}
#[cfg(test)]