diff --git a/Cargo.lock b/Cargo.lock index 35689cbe..ae9a4970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9846,6 +9846,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "smallvec", "smol", "system-configuration", "system-configuration-sys", diff --git a/assets/icons/git-commit.svg b/assets/icons/git-commit.svg new file mode 100644 index 00000000..552e6b17 --- /dev/null +++ b/assets/icons/git-commit.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/git-sync.svg b/assets/icons/git-sync.svg new file mode 100644 index 00000000..87ae7376 --- /dev/null +++ b/assets/icons/git-sync.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index 50520464..d58d6dc7 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -48,6 +48,12 @@ sha2 = "0.11" # implementation. ignore = "0.4" +# Lane assignment for the commit graph (`core::git::log`) keeps a couple of +# parents and a handful of edges per row; a SmallVec keeps those off the heap +# for the shapes that make up almost all of a real history. Already in the tree +# via the GUI, so this pins no new code. +smallvec.workspace = true + # Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, # behind one blocking `Read`/`Write`/`resize` API. This is what lets # `daemon::pane` share a single code path across platforms instead of diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 9dd16184..50a40989 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -168,6 +168,15 @@ pub struct Config { pub right_panel_width: f32, #[serde(default, deserialize_with = "de_lenient")] pub right_panel_tab: RightPanelTab, + /// Global, not per-overlay — the same call VS Code's + /// `diffEditor.renderSideBySide` makes. + #[serde(default, deserialize_with = "de_lenient")] + pub diff_view: DiffViewMode, + /// The source control panel's history section starts collapsed: a graph + /// unfurling the first time someone opens the panel is a worse first + /// impression than one they asked for. + #[serde(default)] + pub scm_graph_expanded: bool, #[serde(default, deserialize_with = "de_lenient")] pub sidebar_grouping: SidebarGrouping, #[serde(default = "default_true")] @@ -508,6 +517,8 @@ impl Default for Config { right_panel_visible: false, right_panel_width: default_right_panel_width(), right_panel_tab: RightPanelTab::Info, + diff_view: DiffViewMode::Split, + scm_graph_expanded: false, sidebar_grouping: SidebarGrouping::Repo, sidebar_diff_preview: true, notify_on_command_finish: NotifyMode::Unfocused, @@ -828,10 +839,28 @@ fn default_prefix() -> String { pub enum RightPanelTab { #[default] Info, - Changes, + /// The source control panel. Renamed from `Changes` in place rather than + /// added alongside it: `rename` works in both directions, so a config + /// written by this version still says `"changes"` and an older build reads + /// it back unchanged. A fourth variant could not do that — the old build + /// would fall through `de_lenient` to `Info` and kick anyone who rolled + /// back off the panel they were sitting on. 260px has no room for a fourth + /// tab tile either. + #[serde(rename = "changes", alias = "scm", alias = "git")] + Scm, Files, } +/// How the diff overlay lays a file out. Side-by-side is the default because +/// that is what everyone already sees. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiffViewMode { + #[default] + Split, + Unified, +} + fn default_right_panel_width() -> f32 { 260. } diff --git a/crates/tty7-core/src/core/git.rs b/crates/tty7-core/src/core/git.rs deleted file mode 100644 index eda88e62..00000000 --- a/crates/tty7-core/src/core/git.rs +++ /dev/null @@ -1,386 +0,0 @@ -use std::io::{self, Read as _}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use crate::host::{Host, Output}; - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct GitStatus { - pub branch: String, - pub added: u32, - pub removed: u32, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct RepoSnapshot { - pub root: PathBuf, - pub home: PathBuf, - pub branch: String, - pub counts: Option<(u32, u32)>, -} - -pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - let paths = git( - host, - cwd, - &[ - "rev-parse", - "--path-format=absolute", - "--show-toplevel", - "--git-dir", - "--git-common-dir", - ], - )?; - let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); - let root = PathBuf::from(lines.next()?); - let home = repo_home(&root, lines.next(), lines.next()); - let branch = branch_name(host, cwd)?; - Some(RepoSnapshot { - home, - root, - branch, - counts: diff_numstat(host, cwd), - }) -} - -fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { - let (Some(git_dir), Some(common)) = (git_dir, common_dir) else { - return root.to_path_buf(); - }; - if git_dir == common { - return root.to_path_buf(); - } - let common = Path::new(common); - match (common.file_name(), common.parent()) { - (Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(), - _ => common.to_path_buf(), - } -} - -pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option { - if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { - let name = out.trim(); - if !name.is_empty() { - return Some(name.to_string()); - } - } - let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?; - let sha = sha.trim(); - (!sha.is_empty()).then(|| sha.to_string()) -} - -fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> { - let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?; - let mut added = 0u32; - let mut removed = 0u32; - for line in out.lines() { - let mut fields = line.split('\t'); - if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { - added += n; - } - if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { - removed += n; - } - } - Some((added, removed)) -} - -pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { - let out = host.git(cwd, args).ok()?; - if !out.success() { - return None; - } - String::from_utf8(out.stdout).ok() -} - -pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { - if !cwd.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("git cwd does not exist: {}", cwd.display()), - )); - } - let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(cwd) - .args(args) - .env("GIT_OPTIONAL_LOCKS", "0") - .env_remove("GIT_DIR") - .env_remove("GIT_WORK_TREE") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let out = crate::core::proc::hide_console(&mut cmd).output()?; - Ok(Output { - status: out.status.code(), - stdout: out.stdout, - stderr: out.stderr, - }) -} - -pub fn git_stream( - cwd: &Path, - args: &[&str], - mut on_chunk: impl FnMut(&[u8]) -> bool, -) -> io::Result> { - if !cwd.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("git cwd does not exist: {}", cwd.display()), - )); - } - let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(cwd) - .args(args) - .env("GIT_OPTIONAL_LOCKS", "0") - .env_remove("GIT_DIR") - .env_remove("GIT_WORK_TREE") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?; - let mut stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::other("git stdout was not piped"))?; - - let mut buf = vec![0u8; 64 * 1024]; - let mut read_err = None; - loop { - match stdout.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - if !on_chunk(&buf[..n]) { - break; - } - } - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, - Err(e) => { - read_err = Some(e); - break; - } - } - } - drop(stdout); - let status = child.wait()?; - match read_err { - Some(e) => Err(e), - None => Ok(status.code()), - } -} - -#[derive(Default)] -pub struct LineSplitter { - tail: Vec, - dropped: usize, -} - -pub const MAX_LINE: usize = 1024 * 1024; - -impl LineSplitter { - pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) { - let mut rest = chunk; - while let Some(nl) = rest.iter().position(|b| *b == b'\n') { - let (line, after) = rest.split_at(nl); - if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE { - on_line(&trim_cr(line)); - } else { - self.keep(line); - let joined = std::mem::take(&mut self.tail); - let dropped = std::mem::take(&mut self.dropped); - emit(&joined, dropped, &mut on_line); - } - rest = &after[1..]; - } - self.keep(rest); - } - - pub fn finish(self, mut on_line: impl FnMut(&str)) { - if !self.tail.is_empty() || self.dropped > 0 { - emit(&self.tail, self.dropped, &mut on_line); - } - } - - fn keep(&mut self, bytes: &[u8]) { - let room = MAX_LINE.saturating_sub(self.tail.len()); - let take = room.min(bytes.len()); - self.tail.extend_from_slice(&bytes[..take]); - self.dropped += bytes.len() - take; - } -} - -fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) { - if dropped == 0 { - on_line(&trim_cr(line)); - return; - } - let mut text = String::from_utf8_lossy(line).into_owned(); - text.push_str(&format!( - " …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]" - )); - on_line(&text); -} - -fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> { - let line = match line.last() { - Some(b'\r') => &line[..line.len() - 1], - _ => line, - }; - String::from_utf8_lossy(line) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn h() -> crate::host::SharedHost { - crate::host::local::LocalHost::new() - } - - #[test] - fn line_splitter_rejoins_across_chunks() { - let mut split = LineSplitter::default(); - let mut got = Vec::new(); - split.push(b"alpha\nbe", |l| got.push(l.to_string())); - assert_eq!(got, ["alpha"], "only the complete line so far"); - split.push(b"ta\ngamma\n", |l| got.push(l.to_string())); - split.finish(|l| got.push(l.to_string())); - assert_eq!(got, ["alpha", "beta", "gamma"]); - } - - #[test] - fn line_splitter_handles_crlf_and_a_missing_final_newline() { - let mut split = LineSplitter::default(); - let mut got = Vec::new(); - split.push(b"one\r\ntwo\r\nthree", |l| got.push(l.to_string())); - split.finish(|l| got.push(l.to_string())); - assert_eq!(got, ["one", "two", "three"]); - } - - #[test] - fn line_splitter_replaces_invalid_utf8() { - let mut split = LineSplitter::default(); - let mut got = Vec::new(); - split.push(b"caf\xe9\n", |l| got.push(l.to_string())); - split.finish(|l| got.push(l.to_string())); - assert_eq!(got.len(), 1); - assert!(got[0].starts_with("caf"), "{:?}", got[0]); - } - - #[test] - fn line_splitter_caps_one_absurd_line() { - let mut split = LineSplitter::default(); - let mut got = Vec::new(); - let huge = vec![b'x'; MAX_LINE + 5_000]; - split.push(b"before\n", |l| got.push(l.to_string())); - for piece in huge.chunks(64 * 1024) { - split.push(piece, |l| got.push(l.to_string())); - } - split.push(b"\nafter\n", |l| got.push(l.to_string())); - split.finish(|l| got.push(l.to_string())); - - assert_eq!( - got.len(), - 3, - "{:?}", - got.iter().map(|l| l.len()).collect::>() - ); - assert_eq!(got[0], "before"); - assert_eq!(got[2], "after", "the next line is unaffected"); - assert!( - got[1].starts_with(&"x".repeat(1000)), - "the kept prefix is the real content" - ); - assert!( - got[1].contains("5000 more bytes"), - "the cut says how much went: {}", - &got[1][got[1].len() - 80..] - ); - assert!( - got[1].len() < MAX_LINE + 200, - "nothing past the cap was retained" - ); - } - - #[test] - fn line_splitter_caps_a_final_unterminated_line() { - let mut split = LineSplitter::default(); - let mut got = Vec::new(); - split.push(&vec![b'y'; MAX_LINE + 7], |l| got.push(l.to_string())); - split.finish(|l| got.push(l.to_string())); - assert_eq!(got.len(), 1); - assert!(got[0].contains("7 more bytes"), "{}", got[0]); - } - - #[test] - fn streaming_and_buffered_reads_agree() { - let here = Path::new(env!("CARGO_MANIFEST_DIR")); - let args = ["log", "--oneline", "-n", "40"]; - let Ok(code) = git_stream(here, &args, |_| true) else { - return; - }; - if code != Some(0) { - return; - } - let mut streamed = Vec::new(); - let mut split = LineSplitter::default(); - git_stream(here, &args, |chunk| { - split.push(chunk, |l| streamed.push(l.to_string())); - true - }) - .unwrap(); - split.finish(|l| streamed.push(l.to_string())); - - let buffered = git_output(here, &args).unwrap(); - let expected: Vec = String::from_utf8_lossy(&buffered.stdout) - .lines() - .map(str::to_string) - .collect(); - assert_eq!(streamed, expected); - assert!(!streamed.is_empty(), "this repo has commits"); - } - - #[test] - fn non_repo_is_none() { - let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz"); - let _ = std::fs::create_dir_all(&dir); - assert_eq!(probe(&*h(), &dir), None); - } - - #[test] - fn missing_path_is_none() { - assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None); - } - - #[test] - fn own_repo_has_a_branch_and_root() { - let here = env!("CARGO_MANIFEST_DIR"); - if let Some(snap) = probe(&*h(), Path::new(here)) { - assert!(!snap.branch.is_empty()); - assert!(Path::new(here).starts_with(&snap.root)); - } - } - #[test] - fn repo_home_resolves_worktree_layouts() { - let root = Path::new("/repo/.wt/feat"); - - assert_eq!( - repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")), - PathBuf::from("/repo") - ); - assert_eq!( - repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")), - PathBuf::from("/repo") - ); - assert_eq!( - repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")), - PathBuf::from("/bare.git") - ); - assert_eq!( - repo_home(root, Some("/repo/.git"), None), - root.to_path_buf() - ); - assert_eq!(repo_home(root, None, None), root.to_path_buf()); - } -} diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs new file mode 100644 index 00000000..cb27a3a3 --- /dev/null +++ b/crates/tty7-core/src/core/git/diff.rs @@ -0,0 +1,1915 @@ +use std::path::{Path, PathBuf}; + +use crate::core::git; +use crate::host::Host; + +pub const MAX_LINES_PER_FILE: usize = 2000; + +pub const MAX_TOTAL_LINES: usize = 20_000; + +pub const MAX_FILES_WITH_HUNKS: usize = 500; + +pub const AUTO_COLLAPSE_LINES: u32 = 400; + +pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000; + +pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100; + +pub const MAX_RENDERED_FILES: usize = 300; + +pub const MAX_UNTRACKED: usize = 500; + +/// `-U`. Git's own default, spelled out because the request carries it. +pub const DEFAULT_CONTEXT: u32 = 3; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Truncation { + PerFile, + 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, Debug, Default)] +pub enum DiffSource { + /// `git diff` — unstaged changes. + Worktree, + /// `git diff --cached` — what a commit right now would contain. + Staged, + /// `git diff HEAD` — staged and unstaged together. + #[default] + Head, + /// 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 { + // `core.quotePath=false` is not tidiness: with it on, git renders a + // non-ASCII path in the `diff --git` header as C octal escapes, and + // nothing downstream decodes those — the path would simply be wrong. + let mut argv = strings(&["-c", "core.quotePath=false"]); + match self { + DiffSource::Worktree => argv.push("diff".to_string()), + DiffSource::Staged => argv.extend(strings(&["diff", "--cached"])), + DiffSource::Head => argv.extend(strings(&["diff", "HEAD"])), + // `log -p -1`, not `diff-tree`: `diff-tree` does not honour + // `--first-parent` as a *narrowing* of a merge. Measured on git + // 2.50.1 against a merge of two branches that each added a file: + // `diff-tree -p -m --first-parent` emits both files (one patch per + // parent, concatenated), and dropping `-m` emits nothing at all. + // `log -p -1 --first-parent` gives the one first-parent patch for a + // merge, an ordinary commit and the initial commit alike, so there + // 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, .. } => { + argv.extend(strings(&["log", "-p", "-1", "--format=", "--first-parent"])); + argv.push(rev.clone()); + } + // Three dots: measured from the merge base, so unrelated work on + // `base` does not show up as if `head` had reverted it. + DiffSource::Range { base, head } => { + argv.push("diff".to_string()); + argv.push(format!("{base}...{head}")); + } + } + argv.extend(strings(&[ + "--no-color", + "--no-ext-diff", + "--no-textconv", + "-M", + ])); + argv.push(format!("-U{context}")); + if ignore_whitespace { + argv.push("-w".to_string()); + } + argv + } + + /// Whether untracked files belong in the snapshot. They are a property of + /// the working tree, so a commit or a range has none, and a staged diff + /// does not either — an untracked file is by definition not in the index. + pub fn lists_untracked(&self) -> bool { + matches!(self, DiffSource::Worktree | DiffSource::Head) + } + + /// Whether every rev this source carries can be handed to git as an + /// argument. The same rule log's `is_rev` applies: a rev the caller made + /// up is still a rev git will be handed, and anything that could be read + /// as an option — `--output=…` most damningly — is refused before it + /// reaches an argv. Checked by [`probe_diff`], so no in-tree caller can + /// forget it. + fn revs_are_arguments(&self) -> bool { + let ok = |rev: &str| { + !rev.is_empty() && !rev.starts_with('-') && !rev.contains(|c: char| c.is_control()) + }; + match self { + DiffSource::Worktree | DiffSource::Staged | DiffSource::Head => true, + DiffSource::Commit { rev, .. } => ok(rev), + DiffSource::Range { base, head } => ok(base) && ok(head), + } + } +} + +fn strings(args: &[&str]) -> Vec { + args.iter().map(|a| a.to_string()).collect() +} + +/// How much of a patch is worth keeping in memory. +/// +/// The line counts on [`FileDiff`] stay exact past every one of these; only the +/// retained text is bounded. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct DiffBudget { + pub max_lines_per_file: usize, + pub max_total_lines: usize, + pub max_files_with_hunks: usize, +} + +impl DiffBudget { + /// A whole tree at once: no single file may crowd out the rest. + pub const PANEL: DiffBudget = DiffBudget { + max_lines_per_file: MAX_LINES_PER_FILE, + max_total_lines: MAX_TOTAL_LINES, + max_files_with_hunks: MAX_FILES_WITH_HUNKS, + }; + + /// One file the user asked for by name. There is nothing to crowd out, so + /// the per-file cap rises to where it is a defence against a generated + /// file rather than a rationing rule. + pub const SINGLE_FILE: DiffBudget = DiffBudget { + max_lines_per_file: 50_000, + max_total_lines: MAX_TOTAL_LINES, + max_files_with_hunks: MAX_FILES_WITH_HUNKS, + }; +} + +/// The whole point of the second budget. Pinned here so that trimming +/// [`MAX_LINES_PER_FILE`] one day cannot silently make the two identical. +const _: () = + assert!(DiffBudget::SINGLE_FILE.max_lines_per_file > DiffBudget::PANEL.max_lines_per_file); + +impl Default for DiffBudget { + fn default() -> DiffBudget { + DiffBudget::PANEL + } +} + +/// One patch to fetch and parse. +pub struct DiffRequest<'a> { + pub source: DiffSource, + /// Empty means the whole tree. Every entry must already be a pathspec — + /// `:(literal)…` — because git globs a bare path and a file named `a[b].c` + /// would then match nothing. + pub paths: &'a [String], + pub context: u32, + pub budget: DiffBudget, + pub ignore_whitespace: bool, +} + +impl Default for DiffRequest<'_> { + fn default() -> DiffRequest<'static> { + DiffRequest { + source: DiffSource::default(), + paths: &[], + context: DEFAULT_CONTEXT, + budget: DiffBudget::PANEL, + ignore_whitespace: false, + } + } +} + +impl DiffRequest<'_> { + pub fn args(&self) -> Vec { + let mut argv = self.source.args(self.context, self.ignore_whitespace); + if !self.paths.is_empty() { + argv.push("--".to_string()); + argv.extend(self.paths.iter().cloned()); + } + argv + } +} + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct DiffSnapshot { + pub root: PathBuf, + pub source: DiffSource, + pub branch: String, + pub files: Vec, + pub untracked: Vec, + pub untracked_total: usize, + pub read_failed: bool, +} + +impl DiffSnapshot { + pub fn totals(&self) -> (u32, u32) { + self.files + .iter() + .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) + } + + pub fn untracked_count(&self) -> usize { + self.untracked_total.max(self.untracked.len()) + } + + pub fn stats(&self) -> DiffStats { + let mut added = 0u32; + let mut removed = 0u32; + let mut retained_lines = 0usize; + let mut budget_exhausted = false; + let mut per_file_truncated = false; + for file in &self.files { + added += file.added; + removed += file.removed; + retained_lines += file.hunks.iter().map(|h| h.lines.len()).sum::(); + match file.truncated { + Some(Truncation::Budget) => budget_exhausted = true, + Some(Truncation::PerFile) => per_file_truncated = true, + None => {} + } + } + let untracked_count = self.untracked_count(); + DiffStats { + totals: (added, removed), + retained_lines, + untracked_count, + oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES + || retained_lines > AUTO_COLLAPSE_TOTAL_LINES, + budget_exhausted, + per_file_truncated, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct DiffStats { + pub totals: (u32, u32), + pub retained_lines: usize, + pub untracked_count: usize, + pub oversized: bool, + pub budget_exhausted: bool, + pub per_file_truncated: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FileStatus { + Added, + Modified, + Deleted, + Renamed, + Copied, + /// Regular file ↔ symlink ↔ submodule. Not a mode change: `100644` to + /// `100755` is still [`FileStatus::Modified`]. + TypeChanged, + /// A path with conflict markers, or one git refused to diff at all. + Unmerged, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct FileDiff { + pub path: String, + pub old_path: Option, + pub status: FileStatus, + pub added: u32, + pub removed: u32, + pub binary: bool, + pub truncated: Option, + pub hunks: Vec, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Hunk { + pub header: String, + pub lines: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LineKind { + Context, + Added, + Removed, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct DiffLine { + pub kind: LineKind, + pub old_no: Option, + pub new_no: Option, + pub text: String, +} + +/// The whole working tree against `HEAD`, on the panel's budget. +pub fn probe(host: &dyn Host, cwd: &Path) -> Option { + probe_diff(host, cwd, &DiffRequest::default()) +} + +/// A whole file rendered as one addition — what an *untracked* file looks +/// like as a patch. git cannot produce this one: `diff` does not know the +/// file, and `--no-index` needs a null device whose spelling is platform +/// business. So the overlay reads the bytes and this builds the same model a +/// parsed patch would, on the same budget a real single-file patch gets — +/// `added` stays the true count past every truncation, like the parser's. +pub fn synthesize_added(path: &str, bytes: &[u8], budget: &DiffBudget) -> FileDiff { + // The same test git itself applies: a NUL anywhere in the first 8000 + // bytes means binary. + let binary = bytes[..bytes.len().min(8000)].contains(&0); + let mut file = FileDiff { + path: path.to_string(), + old_path: None, + status: FileStatus::Added, + added: 0, + removed: 0, + binary, + truncated: None, + hunks: Vec::new(), + }; + if binary { + return file; + } + let text = String::from_utf8_lossy(bytes); + let total = text.lines().count(); + file.added = total as u32; + if total == 0 { + return file; + } + let mut lines = Vec::new(); + for (i, line) in text.lines().enumerate() { + if i >= budget.max_lines_per_file { + file.truncated = Some(Truncation::PerFile); + break; + } + lines.push(DiffLine { + kind: LineKind::Added, + old_no: None, + new_no: Some(i as u32 + 1), + text: line.to_string(), + }); + } + file.hunks.push(Hunk { + header: format!("@@ -0,0 +1,{total} @@"), + lines, + }); + file +} + +pub fn probe_diff(host: &dyn Host, root: &Path, req: &DiffRequest<'_>) -> Option { + if !req.source.revs_are_arguments() { + return None; + } + let toplevel = git::git(host, root, &["rev-parse", "--show-toplevel"])?; + let toplevel = PathBuf::from(toplevel.trim_end_matches(['\n', '\r'])); + let branch = git::branch_name(host, root)?; + + let argv = req.args(); + let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); + let mut parser = DiffParser::with_budget(req.budget); + let diffed = host.git_lines(root, &argv, &mut |line| parser.push_line(line)); + let files = match diffed { + Ok(Some(0)) => parser.finish(), + _ => Vec::new(), + }; + + let mut untracked: Vec = Vec::new(); + let mut untracked_total = 0usize; + // A pathspec-limited request is answering "what changed in these files", + // so a list of everything else in the tree would be noise. + let list_untracked = req.source.lists_untracked() && req.paths.is_empty(); + let listed = list_untracked.then(|| { + host.git_lines( + root, + &[ + "-c", + "core.quotePath=false", + "ls-files", + "--others", + "--exclude-standard", + "--full-name", + ], + &mut |line| { + untracked_total += 1; + if untracked.len() < MAX_UNTRACKED { + untracked.push(line.to_string()); + } + }, + ) + }); + let untracked_ok = match &listed { + Some(listed) => matches!(listed, Ok(Some(0))), + None => true, + }; + if !untracked_ok { + untracked.clear(); + untracked_total = 0; + } + + Some(DiffSnapshot { + root: toplevel, + source: req.source.clone(), + branch, + files, + untracked, + untracked_total, + read_failed: !matches!(diffed, Ok(Some(0))) || !untracked_ok, + }) +} + +#[cfg(test)] +pub fn parse_unified(out: &str) -> Vec { + let mut parser = DiffParser::default(); + for line in out.lines() { + parser.push_line(line); + } + parser.finish() +} + +#[derive(Default)] +pub struct DiffParser { + budget: DiffBudget, + files: Vec, + old_no: u32, + new_no: u32, + file_lines: usize, + total_lines: usize, + files_with_hunks: usize, + in_hunk: bool, + /// How many marker columns the current hunk's body lines carry: one for an + /// ordinary patch, one per parent for a combined (`diff --cc`) one. + markers: usize, + old_mode: String, +} + +impl DiffParser { + pub fn with_budget(budget: DiffBudget) -> DiffParser { + DiffParser { + budget, + ..DiffParser::default() + } + } + + pub fn push_line(&mut self, line: &str) { + if let Some(rest) = line.strip_prefix("diff --git ") { + let (old_p, new_p) = parse_git_header_paths(rest); + self.start_file(new_p.clone(), (old_p != new_p).then_some(old_p), None); + return; + } + // A conflicted path comes out as a combined diff against every merge + // parent at once, which is git's only way of saying "unmerged" in a + // patch — the `diff --git` header has no status field. + if let Some(rest) = line + .strip_prefix("diff --cc ") + .or_else(|| line.strip_prefix("diff --combined ")) + { + let path = parse_combined_header_path(rest); + self.start_file(path, None, Some(FileStatus::Unmerged)); + return; + } + // `git diff --cached` cannot show a conflicted path at all and says so + // on its own line, before any patch. + if let Some(rest) = line.strip_prefix("* Unmerged path ") { + self.start_file(rest.to_string(), None, Some(FileStatus::Unmerged)); + return; + } + let old_mode = std::mem::take(&mut self.old_mode); + let Some(file) = self.files.last_mut() else { + return; + }; + if line.starts_with("new file mode") { + file.status = FileStatus::Added; + return; + } + if line.starts_with("deleted file mode") { + file.status = FileStatus::Deleted; + return; + } + // These four carry one unambiguous path each — unlike the `diff --git` + // header, where two unquoted paths containing ` b/` cannot be split + // reliably (git does not quote a path for a mere space). They land + // after the header, so what they say overrides what it guessed. + if let Some(old) = line.strip_prefix("rename from ") { + file.status = FileStatus::Renamed; + file.old_path = Some(unquote_path(old)); + return; + } + if let Some(old) = line.strip_prefix("copy from ") { + file.status = FileStatus::Copied; + file.old_path = Some(unquote_path(old)); + return; + } + if let Some(new) = line + .strip_prefix("rename to ") + .or_else(|| line.strip_prefix("copy to ")) + { + file.path = unquote_path(new); + return; + } + if let Some(mode) = line.strip_prefix("old mode ") { + self.old_mode = mode.trim().to_string(); + return; + } + if let Some(mode) = line.strip_prefix("new mode ") { + if !old_mode.is_empty() && object_type(&old_mode) != object_type(mode.trim()) { + file.status = FileStatus::TypeChanged; + } + return; + } + if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { + file.binary = true; + return; + } + if !self.in_hunk + && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) + && !line.starts_with("@@") + { + return; + } + if line.starts_with("@@") { + self.in_hunk = true; + // `@@` is one marker column, `@@@` two — a combined diff carries + // one per merge parent. Set before any early return: the line + // counts below stay exact even for a file whose body was dropped. + self.markers = line.bytes().take_while(|b| *b == b'@').count().max(2) - 1; + if file.truncated.is_some() { + return; + } + let first_hunk = file.hunks.is_empty(); + if (first_hunk && self.files_with_hunks >= self.budget.max_files_with_hunks) + || self.total_lines >= self.budget.max_total_lines + { + file.truncated = Some(Truncation::Budget); + return; + } + if first_hunk { + self.files_with_hunks += 1; + } + let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0)); + self.old_no = o; + self.new_no = n; + file.hunks.push(Hunk { + header: line.to_string(), + lines: Vec::new(), + }); + return; + } + if !self.in_hunk { + return; + } + let Some((kind, sides, text)) = split_body_line(line, self.markers) else { + return; + }; + match kind { + LineKind::Added => file.added += 1, + LineKind::Removed => file.removed += 1, + LineKind::Context => {} + } + if file.truncated.is_some() { + return; + } + self.file_lines += 1; + if self.file_lines > self.budget.max_lines_per_file { + file.truncated = Some(Truncation::PerFile); + return; + } + if self.total_lines >= self.budget.max_total_lines { + file.truncated = Some(Truncation::Budget); + return; + } + let Some(hunk) = file.hunks.last_mut() else { + return; + }; + // Numbered by side, not by colour: in a combined diff a ` +` line is + // painted as an addition but *exists* in the first parent, and the + // old-side counter has to walk past it or every number below drifts. + let o = sides.in_old.then(|| { + let o = self.old_no; + self.old_no += 1; + o + }); + let n = sides.in_new.then(|| { + let n = self.new_no; + self.new_no += 1; + n + }); + hunk.lines.push(DiffLine { + kind, + old_no: o, + new_no: n, + text: text.to_string(), + }); + self.total_lines += 1; + } + + pub fn finish(self) -> Vec { + fold_type_changes(self.files) + } + + fn start_file(&mut self, path: String, old_path: Option, status: Option) { + self.files.push(FileDiff { + path, + old_path, + status: status.unwrap_or(FileStatus::Modified), + added: 0, + removed: 0, + binary: false, + truncated: None, + hunks: Vec::new(), + }); + self.file_lines = 0; + self.in_hunk = false; + self.markers = 1; + self.old_mode.clear(); + } +} + +/// A path whose *type* changed is not one patch: git emits the deletion and the +/// creation back to back, and the header of neither says why. The adjacent pair +/// over one path is the whole signal — git has no other reason to produce it — +/// so it is folded back into the single entry the user thinks of. +fn fold_type_changes(files: Vec) -> Vec { + let mut folded: Vec = Vec::with_capacity(files.len()); + for file in files { + let pair = folded.last().is_some_and(|prev| { + prev.status == FileStatus::Deleted + && file.status == FileStatus::Added + && prev.path == file.path + }); + match folded.last_mut().filter(|_| pair) { + Some(prev) => { + prev.status = FileStatus::TypeChanged; + prev.added += file.added; + prev.removed += file.removed; + prev.binary |= file.binary; + prev.truncated = prev.truncated.or(file.truncated); + prev.hunks.extend(file.hunks); + } + None => folded.push(file), + } + } + folded +} + +/// The `100`/`120`/`160` of a git file mode: regular file, symlink, submodule. +/// Permission bits are deliberately dropped — `100644` to `100755` is a +/// modification, not a type change. +fn object_type(mode: &str) -> &str { + &mode[..mode.len().min(3)] +} + +/// Splits a hunk body line into its kind, which sides it exists on, and its +/// text, given how many marker columns the hunk carries. A combined diff marks +/// a line per parent; one `+` or `-` anywhere in those columns settles the +/// *colour*, but the line numbers come from the sides: +/// +/// - the line is in the result iff no column says `-`; +/// - the line is in the first parent — the side tty7 numbers — iff its own +/// column says `-`, or says ` ` on a line that is in the result. (` ` on a +/// line outside the result is the other parent's removal; the first parent +/// never had it.) +/// +/// For an ordinary one-column diff this reduces to exactly `+`/`-`/context. +fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, LineSides, &str)> { + let head = line.get(..markers)?; + let kind = if head.contains('+') { + LineKind::Added + } else if head.contains('-') { + LineKind::Removed + } else if head.bytes().all(|b| b == b' ') { + LineKind::Context + } else { + return None; + }; + let in_new = !head.contains('-'); + let first = head.as_bytes().first().copied(); + let in_old = first == Some(b'-') || (first == Some(b' ') && in_new); + Some((kind, LineSides { in_old, in_new }, &line[markers..])) +} + +/// Which sides of the diff a body line exists on. See [`split_body_line`]. +#[derive(Clone, Copy)] +struct LineSides { + in_old: bool, + in_new: bool, +} + +fn is_hunk_line(line: &str) -> bool { + matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() +} + +fn parse_git_header_paths(rest: &str) -> (String, String) { + if rest.starts_with('"') { + let parts: Vec = parse_quoted_pair(rest); + if parts.len() == 2 { + return (strip_prefix_ab(&parts[0]), strip_prefix_ab(&parts[1])); + } + } + if let Some(idx) = rest.rfind(" b/") { + let old = &rest[..idx]; + let new = &rest[idx + 1..]; + return (strip_prefix_ab(old), strip_prefix_ab(new)); + } + (rest.to_string(), rest.to_string()) +} + +fn parse_quoted_pair(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut in_quote = false; + let mut escaped = false; + for ch in s.chars() { + if escaped { + cur.push(ch); + escaped = false; + continue; + } + match ch { + // The backslash stays in `cur`: the scanner only needs to know the + // next `"` does not close the quote; `c_unescape` does the decode. + '\\' if in_quote => { + cur.push('\\'); + escaped = true; + } + '"' => { + if in_quote { + parts.push(c_unescape(&std::mem::take(&mut cur))); + } + in_quote = !in_quote; + } + _ if in_quote => cur.push(ch), + _ => {} + } + } + parts +} + +/// A single path as written after `rename from ` and friends: C-quoted when it +/// carries a character git always quotes (a control character or a `"` — +/// `core.quotePath=false` stops the quoting of non-ASCII only), bare +/// otherwise. +fn unquote_path(s: &str) -> String { + let s = s.trim_end_matches(['\n', '\r']); + match s + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + { + Some(inner) => c_unescape(inner), + None => s.to_string(), + } +} + +/// Decodes the C escapes git writes inside a quoted path — the full set, not +/// just `\"` and `\\`: a path with a real tab arrives as `\t`, and decoding it +/// to a literal `t` breaks the `:(literal)` re-probe for that row. Octal +/// escapes are *bytes* — a multi-byte character arrives as several — so the +/// value is assembled as bytes and read back as UTF-8 at the end. +fn c_unescape(s: &str) -> String { + let mut bytes: Vec = Vec::with_capacity(s.len()); + let mut push_char = |bytes: &mut Vec, ch: char| { + let mut buf = [0u8; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + }; + let mut it = s.chars().peekable(); + while let Some(ch) = it.next() { + if ch != '\\' { + push_char(&mut bytes, ch); + continue; + } + match it.next() { + Some(digit @ '0'..='7') => { + let mut value = digit as u32 - '0' as u32; + for _ in 0..2 { + match it.peek() { + Some(&next @ '0'..='7') => { + value = value * 8 + (next as u32 - '0' as u32); + it.next(); + } + _ => break, + } + } + bytes.push(value as u8); + } + Some('n') => bytes.push(b'\n'), + Some('t') => bytes.push(b'\t'), + Some('r') => bytes.push(b'\r'), + Some('a') => bytes.push(0x07), + Some('b') => bytes.push(0x08), + Some('f') => bytes.push(0x0c), + Some('v') => bytes.push(0x0b), + // `\"`, `\\`, and anything git never writes: the character itself. + Some(other) => push_char(&mut bytes, other), + None => {} + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn strip_prefix_ab(p: &str) -> String { + p.strip_prefix("a/") + .or_else(|| p.strip_prefix("b/")) + .unwrap_or(p) + .to_string() +} + +/// A combined hunk header carries one `-` range per merge parent before the +/// single `+` range, so the ranges are read positionally rather than by a fixed +/// shape. The first `-` is the first parent's, which is the side tty7 numbers. +fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> { + let ranges = line.trim_start_matches('@'); + let ranges = &ranges[..ranges.find(" @@")?]; + let mut old = None; + let mut new = None; + for range in ranges.split_whitespace() { + let (sign, rest) = range.split_at_checked(1)?; + let start: u32 = rest.split(',').next()?.parse().ok()?; + match sign { + "-" if old.is_none() => old = Some(start), + "+" => new = Some(start), + _ => {} + } + } + Some((old?, new?)) +} + +/// `diff --cc `: one path, repo-relative, with no `a/` or `b/` prefix. +fn parse_combined_header_path(rest: &str) -> String { + match rest.starts_with('"') { + true => parse_quoted_pair(rest) + .pop() + .unwrap_or_else(|| rest.to_string()), + false => rest.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::git::test_support::{PINS, pin_repo_config}; + + const SAMPLE: &str = "\ +diff --git a/src/main.rs b/src/main.rs +index 1111111..2222222 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -10,4 +10,5 @@ fn main() { + let a = 1; +-let b = old(); ++let b = new(); ++let c = 3; + done(); +diff --git a/docs/new.md b/docs/new.md +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/docs/new.md +@@ -0,0 +1,2 @@ ++hello ++world +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index 4444444..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,1 +0,0 @@ +-bye +diff --git a/img.png b/img.png +index 5555555..6666666 100644 +Binary files a/img.png and b/img.png differ +"; + + #[test] + fn parses_the_four_file_shapes() { + let files = parse_unified(SAMPLE); + assert_eq!(files.len(), 4); + + let m = &files[0]; + assert_eq!(m.path, "src/main.rs"); + assert_eq!(m.status, FileStatus::Modified); + assert_eq!((m.added, m.removed), (2, 1)); + assert_eq!(m.hunks.len(), 1); + assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {"); + let lines = &m.hunks[0].lines; + assert_eq!(lines.len(), 5); + assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10))); + assert_eq!(lines[1].kind, LineKind::Removed); + assert_eq!(lines[1].old_no, Some(11)); + assert_eq!(lines[1].new_no, None); + assert_eq!(lines[2].kind, LineKind::Added); + assert_eq!(lines[2].new_no, Some(11)); + assert_eq!(lines[3].new_no, Some(12)); + assert_eq!(lines[3].text, "let c = 3;"); + assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13))); + + let a = &files[1]; + assert_eq!(a.status, FileStatus::Added); + assert_eq!((a.added, a.removed), (2, 0)); + + let d = &files[2]; + assert_eq!(d.status, FileStatus::Deleted); + assert_eq!((d.added, d.removed), (0, 1)); + + let b = &files[3]; + assert!(b.binary); + assert!(b.hunks.is_empty()); + } + + fn argv(source: DiffSource) -> Vec { + source.args(DEFAULT_CONTEXT, false) + } + + #[test] + fn every_source_spells_out_its_own_argv() { + let common = ["--no-color", "--no-ext-diff", "--no-textconv", "-M", "-U3"]; + let cases: Vec<(DiffSource, Vec<&str>)> = vec![ + (DiffSource::Worktree, vec!["diff"]), + (DiffSource::Staged, vec!["diff", "--cached"]), + (DiffSource::Head, vec!["diff", "HEAD"]), + ( + DiffSource::commit("deadbeef"), + vec!["log", "-p", "-1", "--format=", "--first-parent", "deadbeef"], + ), + ( + DiffSource::Range { + base: "main".into(), + head: "topic".into(), + }, + vec!["diff", "main...topic"], + ), + ]; + for (source, middle) in cases { + let want: Vec = ["-c", "core.quotePath=false"] + .into_iter() + .chain(middle) + .chain(common) + .map(str::to_string) + .collect(); + assert_eq!(argv(source.clone()), want, "{source:?}"); + } + } + + /// 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, every non-ASCII path arrives as C octal escapes — decodable + // (see below), but the raw spelling needs no decode at all. Verified + // against git 2.50.1: `diff --git + // "a/\344\270\255\346\226\207\345\220\215.txt" …` becomes + // `diff --git a/中文名.txt b/中文名.txt` once this is off. + for source in [ + DiffSource::Worktree, + DiffSource::Staged, + DiffSource::Head, + DiffSource::commit("HEAD"), + DiffSource::Range { + base: "a".into(), + head: "b".into(), + }, + ] { + assert_eq!(&argv(source.clone())[..2], ["-c", "core.quotePath=false"]); + } + } + + /// `core.quotePath=false` stops the quoting of non-ASCII only. A path + /// with a control character or a `"` is *always* C-quoted, so the decoder + /// has to speak the whole escape set — a tab decoded to a literal `t` + /// names a path that does not exist, and the `:(literal)` re-probe for + /// that row comes back empty. + #[test] + fn c_quoted_paths_decode_the_full_escape_set() { + let escaped = parse_unified( + "diff --git \"a/\\344\\270\\255\\346\\226\\207\\345\\220\\215.txt\" \ + \"b/\\344\\270\\255\\346\\226\\207\\345\\220\\215.txt\"\n", + ); + assert_eq!(escaped[0].path, "中文名.txt"); + + let control = parse_unified("diff --git \"a/x\\ty.rs\" \"b/x\\ty.rs\"\n"); + assert_eq!(control[0].path, "x\ty.rs"); + + let quote = + parse_unified("diff --git \"a/he said \\\"hi\\\".md\" \"b/he said \\\"hi\\\".md\"\n"); + assert_eq!(quote[0].path, "he said \"hi\".md"); + + let raw = parse_unified("diff --git a/中文名.txt b/中文名.txt\n"); + assert_eq!(raw[0].path, "中文名.txt"); + } + + /// The synthesized card for an untracked file mirrors what a parsed + /// added-file patch looks like: true counts past the budget, a hunk + /// header the renderer can show, git's own binary rule. + #[test] + fn an_untracked_file_synthesizes_as_one_addition() { + let file = synthesize_added("notes.md", b"one\ntwo\nthree\n", &DiffBudget::SINGLE_FILE); + assert_eq!(file.status, FileStatus::Added); + assert_eq!((file.added, file.removed), (3, 0)); + assert!(!file.binary); + assert_eq!(file.hunks.len(), 1); + assert_eq!(file.hunks[0].header, "@@ -0,0 +1,3 @@"); + let lines = &file.hunks[0].lines; + assert_eq!(lines.len(), 3); + assert!(lines.iter().all(|l| l.kind == LineKind::Added)); + assert_eq!((lines[2].old_no, lines[2].new_no), (None, Some(3))); + assert_eq!(lines[2].text, "three"); + + let empty = synthesize_added("empty", b"", &DiffBudget::SINGLE_FILE); + assert_eq!(empty.added, 0); + assert!(empty.hunks.is_empty(), "no hunk for a file with no lines"); + + let binary = synthesize_added("blob.png", b"\x89PNG\x00\x01", &DiffBudget::SINGLE_FILE); + assert!(binary.binary); + assert!(binary.hunks.is_empty()); + + let over = "x\n".repeat(DiffBudget::SINGLE_FILE.max_lines_per_file + 5); + let over = synthesize_added("big.txt", over.as_bytes(), &DiffBudget::SINGLE_FILE); + assert_eq!(over.truncated, Some(Truncation::PerFile)); + assert_eq!( + over.added as usize, + DiffBudget::SINGLE_FILE.max_lines_per_file + 5, + "the count stays true past the budget" + ); + assert_eq!( + over.hunks[0].lines.len(), + DiffBudget::SINGLE_FILE.max_lines_per_file + ); + } + + /// git never quotes a path for a mere space, so a `diff --git` header + /// whose paths contain ` b/` cannot be split reliably — but the `rename + /// from`/`rename to` lines that follow name one path each, and they win. + #[test] + fn rename_lines_override_an_ambiguous_header() { + let files = parse_unified( + "diff --git a/my b/old.rs b/my b/new.rs\n\ + similarity index 90%\n\ + rename from my b/old.rs\n\ + rename to my b/new.rs\n", + ); + assert_eq!(files[0].path, "my b/new.rs"); + assert_eq!(files[0].old_path.as_deref(), Some("my b/old.rs")); + assert_eq!(files[0].status, FileStatus::Renamed); + } + + /// A rev that could be read as an option never reaches git — same guard + /// log's `is_rev` applies, on the module that calls itself the one place + /// to read what git is asked. + #[test] + fn a_rev_shaped_like_an_option_never_reaches_git() { + let host = crate::host::local::LocalHost::new(); + for source in [ + DiffSource::commit("--output=/tmp/pwned"), + DiffSource::Range { + base: "--output=/tmp/pwned".into(), + head: "main".into(), + }, + DiffSource::Range { + base: "main".into(), + head: "".into(), + }, + ] { + let req = DiffRequest { + source, + ..DiffRequest::default() + }; + assert!( + probe_diff(&*host, Path::new("/"), &req).is_none(), + "refused before any git runs" + ); + } + } + + #[test] + fn a_request_appends_its_pathspecs_after_a_separator() { + let paths = [":(literal)src/a[b].rs".to_string()]; + let req = DiffRequest { + source: DiffSource::Staged, + paths: &paths, + context: 0, + budget: DiffBudget::SINGLE_FILE, + ignore_whitespace: true, + }; + assert_eq!( + req.args(), + [ + "-c", + "core.quotePath=false", + "diff", + "--cached", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "-M", + "-U0", + "-w", + "--", + ":(literal)src/a[b].rs", + ] + ); + assert_eq!( + DiffRequest::default() + .args() + .iter() + .filter(|a| *a == "--") + .count(), + 0, + "a whole-tree request has nothing to separate" + ); + } + + #[test] + fn the_default_request_is_the_panel_probe() { + let req = DiffRequest::default(); + assert_eq!(req.source, DiffSource::Head); + assert_eq!(req.budget, DiffBudget::PANEL); + assert_eq!(req.args(), argv(DiffSource::Head)); + } + + #[test] + fn a_default_snapshot_still_reads_as_head() { + let snap = DiffSnapshot::default(); + assert_eq!( + snap.source, + DiffSource::Head, + "`..Default::default()` callers keep the source they always had" + ); + } + + #[test] + fn the_single_file_budget_only_lifts_the_per_file_cap() { + assert_eq!(DiffBudget::PANEL, DiffBudget::default()); + assert_eq!( + DiffBudget::SINGLE_FILE.max_total_lines, + DiffBudget::PANEL.max_total_lines + ); + assert_eq!( + DiffBudget::SINGLE_FILE.max_files_with_hunks, + DiffBudget::PANEL.max_files_with_hunks + ); + + let mut out = String::from( + "diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n", + ); + for i in 0..3000 { + out.push_str(&format!("+line {i}\n")); + } + let mut parser = DiffParser::with_budget(DiffBudget::SINGLE_FILE); + for line in out.lines() { + parser.push_line(line); + } + let files = parser.finish(); + assert_eq!(files[0].truncated, None, "3000 lines fit under 50_000"); + assert_eq!(files[0].hunks[0].lines.len(), 3000); + } + + #[test] + fn untracked_files_belong_to_the_working_tree_only() { + assert!(DiffSource::Worktree.lists_untracked()); + assert!(DiffSource::Head.lists_untracked()); + assert!(!DiffSource::Staged.lists_untracked()); + assert!(!DiffSource::commit("x").lists_untracked()); + assert!( + !DiffSource::Range { + base: "a".into(), + head: "b".into() + } + .lists_untracked() + ); + } + + #[test] + fn parses_renames() { + let out = "\ +diff --git a/old/name.rs b/new/name.rs +similarity index 100% +rename from old/name.rs +rename to new/name.rs +"; + let files = parse_unified(out); + assert_eq!(files.len(), 1); + assert_eq!(files[0].status, FileStatus::Renamed); + assert_eq!(files[0].path, "new/name.rs"); + assert_eq!(files[0].old_path.as_deref(), Some("old/name.rs")); + assert_eq!((files[0].added, files[0].removed), (0, 0)); + } + + #[test] + fn parses_copies() { + let out = "\ +diff --git a/tpl.rs b/copy.rs +similarity index 100% +copy from tpl.rs +copy to copy.rs +"; + let files = parse_unified(out); + assert_eq!(files[0].status, FileStatus::Copied); + assert_eq!(files[0].path, "copy.rs"); + assert_eq!(files[0].old_path.as_deref(), Some("tpl.rs")); + } + + #[test] + fn a_type_change_is_one_file_not_a_delete_and_an_add() { + // git 2.50.1, `git diff` over a regular file replaced by a symlink. + let out = "\ +diff --git a/t.txt b/t.txt +deleted file mode 100644 +index 587be6b..0000000 +--- a/t.txt ++++ /dev/null +@@ -1 +0,0 @@ +-x +diff --git a/t.txt b/t.txt +new file mode 120000 +index 0000000..1de5659 +--- /dev/null ++++ b/t.txt +@@ -0,0 +1 @@ ++target +\\ No newline at end of file +"; + let files = parse_unified(out); + assert_eq!(files.len(), 1, "one path, one row"); + assert_eq!(files[0].status, FileStatus::TypeChanged); + assert_eq!((files[0].added, files[0].removed), (1, 1)); + assert_eq!(files[0].hunks.len(), 2, "both halves stay readable"); + } + + #[test] + fn a_permission_change_is_still_a_modification() { + let files = parse_unified("diff --git a/t.sh b/t.sh\nold mode 100644\nnew mode 100755\n"); + assert_eq!(files[0].status, FileStatus::Modified); + } + + #[test] + fn a_mode_change_across_object_types_is_a_type_change() { + let files = parse_unified("diff --git a/t b/t\nold mode 100644\nnew mode 120000\n"); + assert_eq!(files[0].status, FileStatus::TypeChanged); + } + + #[test] + fn a_delete_and_an_add_of_different_paths_stay_two_files() { + let files = parse_unified( + "diff --git a/gone.txt b/gone.txt\ndeleted file mode 100644\n\ + diff --git a/new.txt b/new.txt\nnew file mode 100644\n", + ); + assert_eq!(files.len(), 2); + assert_eq!(files[0].status, FileStatus::Deleted); + assert_eq!(files[1].status, FileStatus::Added); + } + + #[test] + fn a_conflicted_file_parses_as_a_combined_diff() { + // git 2.50.1, `git diff` during a conflicted merge. Two marker columns, + // and a hunk header with one range per parent. + let out = "\ +diff --cc f.txt +index af70335,f794161..0000000 +--- a/f.txt ++++ b/f.txt +@@@ -1,3 -1,3 +1,7 @@@ + a +++<<<<<<< HEAD + +MAIN +++======= ++ SIDE +++>>>>>>> side + c +"; + let files = parse_unified(out); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "f.txt"); + assert_eq!(files[0].status, FileStatus::Unmerged); + + let lines = &files[0].hunks[0].lines; + assert_eq!(lines.len(), 7); + assert_eq!(lines[0].kind, LineKind::Context); + assert_eq!(lines[0].text, "a", "both marker columns come off the text"); + assert_eq!((lines[0].old_no, lines[0].new_no), (Some(1), Some(1))); + assert_eq!(lines[1].kind, LineKind::Added); + assert_eq!(lines[1].text, "<<<<<<< HEAD"); + assert_eq!(lines[4].kind, LineKind::Added); + assert_eq!(lines[4].text, "SIDE", "added on one side is still added"); + assert_eq!(lines[6].text, "c"); + assert_eq!((files[0].added, files[0].removed), (5, 0)); + + // Numbers follow the *sides*, not the colour: ` +MAIN` is painted as + // an addition but exists in the first parent (it is HEAD's own line), + // so the old counter walks past it — and `c` lands on old line 3, + // exactly the `-1,3` the hunk header promises. `+ SIDE` is the other + // parent's line: no old number. + assert_eq!( + (lines[2].old_no, lines[2].new_no), + (Some(2), Some(3)), + "MAIN: {:?}", + lines[2] + ); + assert_eq!((lines[4].old_no, lines[4].new_no), (None, Some(5))); + assert_eq!((lines[6].old_no, lines[6].new_no), (Some(3), Some(7))); + } + + #[test] + fn a_staged_diff_reports_the_paths_it_refused_to_show() { + let files = parse_unified("* Unmerged path f.txt\ndiff --git a/ok.rs b/ok.rs\n"); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "f.txt"); + assert_eq!(files[0].status, FileStatus::Unmerged); + assert!(files[0].hunks.is_empty()); + assert_eq!(files[1].path, "ok.rs"); + } + + #[test] + fn parses_quoted_paths() { + let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n"; + let files = parse_unified(out); + assert_eq!(files[0].path, "has space.txt"); + assert_eq!(files[0].old_path, None); + } + + #[test] + fn triple_dash_content_line_is_kept() { + let out = "\ +diff --git a/x.md b/x.md +index 1111111..2222222 100644 +--- a/x.md ++++ b/x.md +@@ -1,2 +1,1 @@ + keep +---- a heading rule +"; + let files = parse_unified(out); + let lines = &files[0].hunks[0].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[1].kind, LineKind::Removed); + assert_eq!(lines[1].text, "--- a heading rule"); + } + + #[test] + fn caps_lines_per_file_but_keeps_counting() { + let mut out = String::from( + "diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n", + ); + for i in 0..3000 { + out.push_str(&format!("+line {i}\n")); + } + let files = parse_unified(&out); + assert_eq!(files[0].truncated, Some(Truncation::PerFile)); + assert_eq!(files[0].added, 3000); + let kept: usize = files[0].hunks.iter().map(|h| h.lines.len()).sum(); + assert_eq!(kept, MAX_LINES_PER_FILE); + } + + #[test] + fn skips_no_newline_marker() { + let out = "\ +diff --git a/x b/x +index 1..2 100644 +--- a/x ++++ b/x +@@ -1,1 +1,1 @@ +-old +\\ No newline at end of file ++new +\\ No newline at end of file +"; + let files = parse_unified(out); + assert_eq!(files[0].hunks[0].lines.len(), 2); + assert_eq!((files[0].added, files[0].removed), (1, 1)); + } + + #[test] + fn snapshot_totals() { + let snap = DiffSnapshot { + files: parse_unified(SAMPLE), + ..Default::default() + }; + assert_eq!(snap.totals(), (4, 2)); + } + + fn many_files(files: usize, lines_each: usize) -> String { + let mut out = String::new(); + for f in 0..files { + out.push_str(&format!( + "diff --git a/f{f}.rs b/f{f}.rs\nindex 1..2 100644\n--- a/f{f}.rs\n+++ b/f{f}.rs\n@@ -0,0 +1,{lines_each} @@\n" + )); + for i in 0..lines_each { + out.push_str(&format!("+file {f} line {i}\n")); + } + } + out + } + + #[test] + fn repo_wide_budget_caps_retained_lines() { + let files = parse_unified(&many_files(300, 300)); + assert_eq!(files.len(), 300, "every file keeps its header row"); + let retained: usize = files + .iter() + .flat_map(|f| &f.hunks) + .map(|h| h.lines.len()) + .sum(); + assert!( + retained <= MAX_TOTAL_LINES, + "retained {retained} lines, budget is {MAX_TOTAL_LINES}" + ); + assert!( + files + .iter() + .any(|f| f.truncated == Some(Truncation::Budget)) + ); + } + + #[test] + fn repo_wide_budget_keeps_totals_exact() { + let snap = DiffSnapshot { + files: parse_unified(&many_files(300, 300)), + ..Default::default() + }; + assert_eq!(snap.totals(), (90_000, 0)); + assert!(snap.stats().budget_exhausted); + } + + #[test] + fn repo_wide_budget_caps_files_with_hunks() { + let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1)); + assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50); + let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count(); + assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS); + assert_eq!( + files.iter().map(|f| f.added).sum::(), + (MAX_FILES_WITH_HUNKS + 50) as u32 + ); + assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget)); + } + + #[test] + fn small_diff_is_not_truncated() { + let snap = DiffSnapshot { + files: parse_unified(SAMPLE), + ..Default::default() + }; + assert!(snap.files.iter().all(|f| f.truncated.is_none())); + assert!(!snap.stats().oversized); + assert!(!snap.stats().budget_exhausted); + } + + #[test] + fn oversized_trips_on_files_or_lines() { + let by_files = DiffSnapshot { + files: parse_unified(&many_files(AUTO_COLLAPSE_TOTAL_FILES + 1, 1)), + ..Default::default() + }; + assert!(by_files.stats().oversized); + + let per_file = MAX_LINES_PER_FILE / 2; + let by_lines = DiffSnapshot { + files: parse_unified(&many_files( + AUTO_COLLAPSE_TOTAL_LINES / per_file + 1, + per_file, + )), + ..Default::default() + }; + assert!(by_lines.files.len() <= AUTO_COLLAPSE_TOTAL_FILES); + assert!(by_lines.stats().retained_lines > AUTO_COLLAPSE_TOTAL_LINES); + assert!(by_lines.stats().oversized); + } + + #[test] + fn truncated_file_counts_dash_prefixed_content() { + let mut out = many_files(MAX_FILES_WITH_HUNKS, 1); + out.push_str( + "diff --git a/late.md b/late.md\nindex 1..2 100644\n--- a/late.md\n+++ b/late.md\n@@ -1,2 +1,1 @@\n keep\n--- a heading rule\n", + ); + let files = parse_unified(&out); + let late = files.last().unwrap(); + assert_eq!(late.path, "late.md"); + assert_eq!(late.truncated, Some(Truncation::Budget)); + assert!(late.hunks.is_empty(), "no body kept past the file cap"); + assert_eq!((late.added, late.removed), (0, 1), "but the line counts"); + } + + #[test] + fn untracked_is_capped_but_counted() { + let mut untracked: Vec = Vec::new(); + let mut untracked_total = 0usize; + for i in 0..(MAX_UNTRACKED * 3) { + untracked_total += 1; + if untracked.len() < MAX_UNTRACKED { + untracked.push(format!("node_modules/p{i}/index.js")); + } + } + let snap = DiffSnapshot { + untracked, + untracked_total, + ..Default::default() + }; + assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded"); + assert_eq!( + snap.untracked_count(), + MAX_UNTRACKED * 3, + "the count is not" + ); + } + + /// A repo with a merge whose two parents each added a file, plus an + /// ordinary commit and an initial one. Returns `None` if git is missing. + fn merge_repo(name: &str) -> Option { + let dir = std::env::temp_dir().join(format!("tty7-diff-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok()?; + let run = |args: &[&str]| { + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + git::git_output(&dir, &full) + .ok() + .filter(|out| out.success()) + .is_some() + }; + if !run(&["init", "-q", "-b", "mainwork", "."]) { + return None; + } + // Before the first blob is written, and in the repository rather than + // only on this closure's command lines: `probe_diff` runs its own git, + // and on Windows that git would otherwise inherit the system-wide + // `core.autocrlf=true` and rewrite every checkout of this fixture. + assert!(pin_repo_config(&dir)); + std::fs::write(dir.join("base.txt"), "base\n").ok()?; + run(&["add", "-A"]); + run(&["commit", "-qm", "initial"]); + run(&["checkout", "-q", "-b", "side"]); + std::fs::write(dir.join("s.txt"), "side\n").ok()?; + run(&["add", "-A"]); + run(&["commit", "-qm", "side"]); + run(&["checkout", "-q", "mainwork"]); + std::fs::write(dir.join("m.txt"), "main\n").ok()?; + run(&["add", "-A"]); + run(&["commit", "-qm", "main"]); + run(&["merge", "-q", "--no-ff", "-m", "merge side", "side"]); + Some(dir) + } + + fn rev(dir: &Path, spec: &str) -> String { + git::git_output(dir, &["rev-parse", spec]) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .unwrap_or_default() + } + + fn commit_files(host: &dyn Host, dir: &Path, spec: &str) -> Vec { + let req = DiffRequest { + source: DiffSource::commit(rev(dir, spec)), + ..Default::default() + }; + probe_diff(host, dir, &req) + .expect("the scratch repo answers") + .files + .into_iter() + .map(|f| f.path) + .collect() + } + + /// The reason `Commit` runs `log -p -1 --first-parent` and not `diff-tree`: + /// on git 2.50.1 `diff-tree -p -m --first-parent` emits *both* parents' + /// patches concatenated, and without `-m` it emits nothing for a merge at + /// all. Either would show a file the merge did not touch on this side. + #[test] + fn a_merge_commit_yields_only_its_first_parent_patch() { + let Some(dir) = merge_repo("merge") else { + return; + }; + let host = crate::host::local::LocalHost::new(); + + assert_eq!(commit_files(&*host, &dir, "HEAD"), ["s.txt"]); + assert_eq!(commit_files(&*host, &dir, "HEAD^"), ["m.txt"]); + assert_eq!( + commit_files(&*host, &dir, "HEAD^^"), + ["base.txt"], + "the initial commit diffs against the empty tree, not an error" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_three_working_tree_sources_split_staged_from_unstaged() { + let Some(dir) = merge_repo("sources") else { + return; + }; + let host = crate::host::local::LocalHost::new(); + std::fs::write(dir.join("base.txt"), "base\nstaged\n").unwrap(); + let _ = git::git_output(&dir, &["add", "base.txt"]); + std::fs::write(dir.join("m.txt"), "main\nunstaged\n").unwrap(); + std::fs::write(dir.join("fresh.txt"), "new\n").unwrap(); + + let files = |source: DiffSource| -> Vec { + let req = DiffRequest { + source, + ..Default::default() + }; + probe_diff(&*host, &dir, &req) + .expect("the scratch repo answers") + .files + .into_iter() + .map(|f| f.path) + .collect() + }; + assert_eq!(files(DiffSource::Staged), ["base.txt"]); + assert_eq!(files(DiffSource::Worktree), ["m.txt"]); + assert_eq!(files(DiffSource::Head), ["base.txt", "m.txt"]); + + let staged = probe_diff( + &*host, + &dir, + &DiffRequest { + source: DiffSource::Staged, + ..Default::default() + }, + ) + .unwrap(); + assert!( + staged.untracked.is_empty(), + "an untracked file is by definition not staged" + ); + assert_eq!(probe(&*host, &dir).unwrap().untracked, ["fresh.txt"]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_non_ascii_path_survives_the_round_trip() { + let Some(dir) = merge_repo("utf8") else { + return; + }; + let host = crate::host::local::LocalHost::new(); + std::fs::write(dir.join("中文名.txt"), "one\n").unwrap(); + let _ = git::git_output(&dir, &["add", "-A"]); + + let snap = probe_diff( + &*host, + &dir, + &DiffRequest { + source: DiffSource::Staged, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + snap.files + .iter() + .map(|f| f.path.as_str()) + .collect::>(), + ["中文名.txt"], + "octal escapes would have made this `344\\270\\255…`" + ); + + std::fs::write(dir.join("未跟踪.txt"), "two\n").unwrap(); + assert!( + probe(&*host, &dir) + .unwrap() + .untracked + .contains(&"未跟踪.txt".to_string()), + "ls-files quotes the same way, and is unquoted the same way" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_pathspec_narrows_the_patch_and_drops_the_untracked_list() { + let Some(dir) = merge_repo("pathspec") else { + return; + }; + let host = crate::host::local::LocalHost::new(); + std::fs::write(dir.join("base.txt"), "base\nedit\n").unwrap(); + std::fs::write(dir.join("m.txt"), "main\nedit\n").unwrap(); + std::fs::write(dir.join("fresh.txt"), "new\n").unwrap(); + + let paths = [":(literal)m.txt".to_string()]; + let snap = probe_diff( + &*host, + &dir, + &DiffRequest { + source: DiffSource::Worktree, + paths: &paths, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + snap.files + .iter() + .map(|f| f.path.as_str()) + .collect::>(), + ["m.txt"] + ); + assert!(snap.untracked.is_empty()); + assert!(!snap.read_failed); + assert_eq!(snap.source, DiffSource::Worktree); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + #[ignore = "measurement, not an assertion"] + fn bench_stream_vs_buffer() { + use crate::core::git::{LineSplitter, git_output, git_stream}; + use std::time::Instant; + + let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let args = ["log", "-p", "-n", "400", "--no-color"]; + + let t = Instant::now(); + let Ok(out) = git_output(here, &args) else { + println!("no git here; skipping"); + return; + }; + let read = t.elapsed(); + let resident = out.stdout.len(); + let t = Instant::now(); + let buffered_files = parse_unified(&String::from_utf8_lossy(&out.stdout)); + let buffered_parse = t.elapsed(); + println!( + "buffered: {resident} bytes resident, read {read:?}, parse {buffered_parse:?}, \ + {} files", + buffered_files.len() + ); + drop(out); + + let t = Instant::now(); + let mut parser = DiffParser::default(); + let mut split = LineSplitter::default(); + let mut peak = 0usize; + git_stream(here, &args, |chunk| { + peak = peak.max(chunk.len()); + split.push(chunk, |line| parser.push_line(line)); + true + }) + .unwrap(); + split.finish(|line| parser.push_line(line)); + let streamed = t.elapsed(); + let streamed_files = parser.finish(); + println!( + "streamed: peak transient chunk {peak} bytes (vs {resident} resident), \ + read+parse {streamed:?}, {} files", + streamed_files.len() + ); + assert_eq!(buffered_files.len(), streamed_files.len()); + } + + #[test] + #[ignore = "measurement, not an assertion"] + fn bench_parse_budget() { + use std::time::Instant; + let out = many_files(300, 300); + println!("input: {} bytes, 300 files × 300 lines", out.len()); + let t = Instant::now(); + let files = parse_unified(&out); + let elapsed = t.elapsed(); + let retained: usize = files + .iter() + .flat_map(|f| &f.hunks) + .map(|h| h.lines.len()) + .sum(); + let bytes: usize = files + .iter() + .flat_map(|f| &f.hunks) + .flat_map(|h| &h.lines) + .map(|l| l.text.capacity() + std::mem::size_of::()) + .sum(); + println!( + "parse {elapsed:?} → {retained} retained lines, ~{} KiB of DiffLine text \ + (unbudgeted would be 90000 lines / ~{} KiB)", + bytes / 1024, + 90_000 * (24 + std::mem::size_of::()) / 1024, + ); + } +} diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs new file mode 100644 index 00000000..c34840c9 --- /dev/null +++ b/crates/tty7-core/src/core/git/log.rs @@ -0,0 +1,2080 @@ +//! History: the commits themselves, the refs pointing at them, and the lane +//! layout the graph is drawn from. +//! +//! The lane assignment lives here, not in the renderer, for two reasons. It is +//! a pure function over `(sha, parents)` and therefore the part of the graph +//! most worth testing exhaustively; and gpui re-runs `render` on every notify, +//! so an O(commits × lanes) pass in a paint closure would be burned every +//! frame for a result that only changes when the history does. + +use std::collections::HashMap; +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 +/// repositories exist and the extra allocation is noise next to the subject. +pub type Oid = String; + +/// Lane index as assigned by the layout pass — the *true* column, before the +/// renderer folds anything past its width cap into an overflow column. +pub type Lane = u16; + +/// Which palette entry a lane draws with. Equal to the lane it was created for +/// and never reassigned, which is what keeps a branch one colour for its whole +/// life: a branch holds the same lane from its tip until it is merged, because +/// the first parent inherits the lane in place and never migrates. +pub type ColorIdx = u16; + +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; + +/// Record separator for `log --pretty`. Deliberately not NUL: `git log -z` +/// already uses NUL between records, so a NUL field separator could only be +/// told apart by counting fields — and one NUL inside a commit message (git +/// objects allow it) would desynchronise the whole stream. RS and US cannot +/// occur in a sha, a refname, an ISO date or an address. +/// +/// A commit *message* can still carry RS or US — nothing git accepts is out of +/// bounds there. The failure is contained, not eliminated: the body truncates +/// at the stray separator, `is_hex_oid` throws the tail away unless it is +/// deliberately shaped like a full record, and a deliberately shaped one can +/// fabricate at worst a bogus row in the graph — whose `git show` then fails. +/// Sealing that needs length-prefixed reads (`cat-file --batch`), a different +/// data path entirely. +pub const REC_SEP: u8 = 0x1e; +pub const FIELD_SEP: u8 = 0x1f; + +/// A timestamp plus the author's own UTC offset, so times can be shown in the +/// zone they were written in. Parsed from `%aI` / `%cI`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct OffsetTs { + pub unix: i64, + pub offset_minutes: i32, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Signature { + pub name: String, + pub email: String, + pub at: OffsetTs, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum RefKind { + /// Sorts last so the highest-priority chip wins a `max()`. + Other, + RemoteBranch, + Tag, + LocalBranch, + Head, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RefDeco { + pub kind: RefKind, + /// `refs/heads/feature/x` + pub full: String, + /// `feature/x` + 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)] +pub struct Commit { + pub oid: Oid, + pub parents: SmallVec<[Oid; 2]>, + pub author: Signature, + pub committer: Signature, + pub summary: String, + pub body: String, + pub refs: Vec, +} + +impl Commit { + pub fn short(&self) -> &str { + let n = self.oid.len().min(7); + &self.oid[..n] + } + + pub fn is_merge(&self) -> bool { + self.parents.len() > 1 + } +} + +/// One line inside a single row's band: from the row's top edge to its bottom. +/// +/// Row-local on purpose. A model that described whole polylines across rows +/// could not emit a line until its far end arrived, so a long-lived branch +/// would stay invisible until the page holding its parent loaded — the bug +/// Zed's own graph has. Here a row is final the moment it is produced, which +/// is also what makes paging free of visual reflow. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Edge { + /// Straight through the band without touching this row's node. + Pass { lane: Lane, color: ColorIdx }, + /// Comes down from `from` on the top edge and ends at this row's node. + In { from: Lane, color: ColorIdx }, + /// Leaves this row's node for `to` on the bottom edge. + Out { to: Lane, color: ColorIdx }, +} + +impl Edge { + pub fn color(self) -> ColorIdx { + match self { + Edge::Pass { color, .. } | Edge::In { color, .. } | Edge::Out { color, .. } => color, + } + } + + /// Paint order: pass-through lines first, so the node's own line lands on + /// top of anything crossing behind it. + pub fn paint_rank(&self) -> u8 { + match self { + Edge::Pass { .. } => 0, + Edge::In { .. } => 1, + Edge::Out { .. } => 2, + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GraphRow { + pub node: Lane, + pub color: ColorIdx, + /// 0 = root commit, 1 = ordinary, >1 = merge (>2 = octopus). + pub parents: u8, + pub edges: SmallVec<[Edge; 4]>, +} + +/// Which refs the log is walked from. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] +pub enum GraphScope { + Head, + /// HEAD plus its upstream — the default, matching VS Code. + #[default] + HeadAndUpstream, + All, + Refs(Vec), +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CommitPage { + pub commits: Vec, + /// Same length as `commits`. + pub rows: Vec, + pub max_lanes: Lane, + pub scope: GraphScope, + pub requested: usize, + /// git returned fewer than asked for, so this is the end of history. + pub complete: bool, + pub truncated_lanes: bool, + /// Lanes still open past the last row — drawn as fading stubs so a page + /// boundary does not read as a row of root commits. + pub open_lanes: Vec, +} + +/// The append-only lane assigner the rows come out of. +/// +/// Fed `(sha, parents)` newest-first, it produces exactly one [`GraphRow`] per +/// commit and keeps only the state that has to survive a page boundary: which +/// oid each lane is holding a place for, and which lanes are holding a place +/// for a given oid. +/// +/// Append-only is the point. A later page extends the graph without touching a +/// row already on screen, which is only possible because a row says nothing +/// about the rows below it — see [`Edge`]. +#[derive(Default)] +pub struct LaneAlloc { + /// Per lane, the oid that lane is currently waiting for. `None` is free. + slots: Vec>, + /// The reverse index. A `SmallVec` because one child is the common case + /// and the second entry is the other side of a merge; more than two + /// children of one commit is rare enough to spill. + pending: HashMap>, + truncated: bool, +} + +impl LaneAlloc { + pub fn new() -> LaneAlloc { + LaneAlloc::default() + } + + /// Lays out one page of commits, newest first. `--topo-order` is what makes + /// a single forward pass enough: it guarantees a parent is listed after + /// every one of its children, so by the time a commit is reached, every + /// lane that wants it already exists. + pub fn push(&mut self, page: &[(Oid, SmallVec<[Oid; 2]>)], out: &mut Vec) { + out.reserve(page.len()); + for (sha, parents) in page { + let row = self.row(sha, parents); + out.push(row); + } + } + + /// Lanes still held open past the last row laid out — the page boundary. + /// The renderer draws these as stubs so the bottom of a page does not read + /// as a row of root commits. + pub fn open_lanes(&self) -> Vec { + self.slots + .iter() + .enumerate() + .filter(|(_, slot)| slot.is_some()) + .map(|(lane, _)| lane as Lane) + .collect() + } + + /// How many columns are live right now, i.e. one past the rightmost lane in + /// use. Lanes are never compacted, so this only shrinks when the rightmost + /// lane itself dies. + pub fn width(&self) -> Lane { + self.slots + .iter() + .rposition(|slot| slot.is_some()) + .map_or(0, |lane| lane as Lane + 1) + } + + /// Whether history was wider than [`MAX_LANES`] at some point, so lines + /// were forced to share the last lane. Sticky: once true it stays true. + pub fn truncated(&self) -> bool { + self.truncated + } + + fn row(&mut self, sha: &Oid, parents: &[Oid]) -> GraphRow { + // Sorted so the lines entering this row are emitted left to right and + // the node lands on the leftmost of them — mainline hugs the left. + let mut waiting = self.pending.remove(sha).unwrap_or_default(); + waiting.sort_unstable(); + waiting.dedup(); + let node = match waiting.first() { + Some(lane) => *lane, + // Nothing is waiting: no child of this commit is inside the window, + // so it is a tip and starts a lane of its own. + None => self.alloc_lane(&[]), + }; + + let mut edges: SmallVec<[Edge; 4]> = SmallVec::new(); + for (lane, slot) in self.slots.iter().enumerate() { + let lane = lane as Lane; + if slot.is_some() && !waiting.contains(&lane) { + edges.push(Edge::Pass { lane, color: lane }); + } + } + for &lane in &waiting { + edges.push(Edge::In { + from: lane, + color: lane, + }); + // Release the lane so a parent can claim it below — but only if it + // really is this commit's. Past MAX_LANES two commits can share the + // overflow lane, and clearing it there would strand the other one. + if self.slots[lane as usize].as_deref() == Some(sha.as_str()) { + self.slots[lane as usize] = None; + } + } + + // Lanes this row just gave up, plus the node's own. A second parent + // that landed on one of them would draw `lane 2 → node → lane 2`, a V + // that reads as one line bending rather than two lines meeting. + let mut avoid = waiting; + if !avoid.contains(&node) { + avoid.push(node); + } + + let mut claimed: SmallVec<[Lane; 4]> = SmallVec::new(); + for (k, parent) in parents.iter().enumerate() { + let lane = if k == 0 { + // The first parent inherits the node's lane in place, never + // migrating. That is what keeps a branch on one lane — and so + // in one colour — from its tip down to wherever it was merged. + node + } else if let Some(lane) = self + .pending + .get(parent) + .and_then(|lanes| lanes.iter().copied().min()) + { + // Some other child already reserved a lane for this parent. + // Join it instead of opening a second lane to the same commit: + // two lanes converging on one dot is what the merge row itself + // is for, and lanes are the scarce resource. + lane + } else { + self.alloc_lane(&avoid) + }; + if claimed.contains(&lane) { + continue; + } + claimed.push(lane); + if self.slots[lane as usize].as_deref() != Some(parent.as_str()) { + self.slots[lane as usize] = Some(parent.clone()); + self.pending.entry(parent.clone()).or_default().push(lane); + } + edges.push(Edge::Out { + to: lane, + color: lane, + }); + } + // No parents: a root. `slots[node]` was released above and nothing + // claimed it, so the lane simply ends here. + + // Stable: two edges of one rank (a merge's several `Out`s) keep their + // insertion order — first parent first — which the golden tests pin. + edges.sort_by_key(Edge::paint_rank); + GraphRow { + node, + // Colour is the lane number, fixed when the lane is created and + // never reassigned. Not a per-branch counter (what the Git Graph + // extension does): with a palette of N, branch 0 and branch N come + // out the same colour, and in a 3-column panel those two are very + // likely adjacent. Keying on the lane makes neighbouring columns + // maximally distinct by construction, and the "one branch, one + // colour" property falls out of the first parent inheriting in + // place. + color: node, + parents: parents.len().min(u8::MAX as usize) as u8, + edges, + } + } + + /// The lowest free lane, avoiding the given ones if that is possible + /// without widening the graph past [`MAX_LANES`]. + fn alloc_lane(&mut self, avoid: &[Lane]) -> Lane { + let free = self + .slots + .iter() + .enumerate() + .find(|(lane, slot)| slot.is_none() && !avoid.contains(&(*lane as Lane))) + .map(|(lane, _)| lane as Lane); + if let Some(lane) = free { + return lane; + } + if self.slots.len() < MAX_LANES as usize { + self.slots.push(None); + return (self.slots.len() - 1) as Lane; + } + // At the cap the avoidance is dropped rather than honoured: a lane is + // expensive in a 216px panel and a V-shaped kink is only ugly. + if let Some(lane) = self.slots.iter().position(Option::is_none) { + return lane as Lane; + } + // Genuinely out of lanes. Everything past here shares the last one, + // which the caller reports so the renderer can say the graph is + // incomplete rather than quietly drawing a lie. + self.truncated = true; + MAX_LANES - 1 + } +} + +fn row_span(row: &GraphRow) -> Lane { + let mut span = row.node + 1; + for edge in &row.edges { + let lane = match *edge { + Edge::Pass { lane, .. } => lane, + Edge::In { from, .. } => from, + Edge::Out { to, .. } => to, + }; + span = span.max(lane + 1); + } + span +} + +/// The eleven fields, in order: sha, parents, author name/email/date, +/// committer name/email/date, decorations, subject, body. +pub const LOG_PRETTY: &str = + "--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%D%x1f%s%x1f%b"; + +const LOG_FIELDS: usize = 11; + +pub const REF_FORMAT: &str = "--format=%(objectname)%x1f%(refname)%x1f%(refname:short)%x1f%(upstream)%x1f%(HEAD)%x1f%(objecttype)%x1f%(*objectname)"; + +/// What [`parse_log`] read, and whether it read all of it. +pub struct ParsedLog { + pub commits: Vec, + /// The stream was cut short — by [`MAX_LOG_BYTES`], or by a record past + /// `MAX_RECORD` being dropped whole. The caller must not present the + /// commits as "all of history": git returned more than was parsed. + pub truncated: bool, +} + +/// Parses the output of the `log` invocation [`LOG_PRETTY`] belongs to. +/// +/// Records are split on RS and fields on US. Fields are taken with `splitn`, so +/// the body — the only field that can contain anything at all — absorbs every +/// separator past the tenth instead of shifting the parse. +pub fn parse_log(stdout: &[u8]) -> ParsedLog { + let mut commits = Vec::new(); + let mut used = 0usize; + let mut clipped = false; + let mut on_record = |record: &[u8]| { + used = used.saturating_add(record.len()); + if used > MAX_LOG_BYTES { + clipped = true; + return; + } + if let Some(commit) = parse_record(record) { + commits.push(commit); + } + }; + let mut split = RecordSplitter::new(REC_SEP); + split.push(stdout, &mut on_record); + let dropped = split.finish(&mut on_record); + ParsedLog { + commits, + truncated: clipped || dropped > 0, + } +} + +fn parse_record(record: &[u8]) -> Option { + let text = String::from_utf8_lossy(record); + let mut fields = text.splitn(LOG_FIELDS, FIELD_SEP as char); + let oid = fields.next()?; + // The stream opens with an empty record (nothing precedes the first RS), + // and a format drift would otherwise turn one bad record into a page of + // nonsense. Anything that is not a sha is not a commit. + if !is_hex_oid(oid) { + return None; + } + let parents = fields + .next()? + .split_ascii_whitespace() + .map(str::to_string) + .collect(); + let author_name = fields.next()?; + let author_email = fields.next()?; + let author_at = fields.next()?; + let committer_name = fields.next()?; + let committer_email = fields.next()?; + let committer_at = fields.next()?; + let deco = fields.next()?; + let subject = fields.next()?; + // git joins records with a newline, so the last field carries it. + let body = fields.next()?.trim_end_matches(['\n', '\r']); + + Some(Commit { + oid: oid.to_string(), + parents, + author: signature(author_name, author_email, author_at), + committer: signature(committer_name, committer_email, committer_at), + summary: clip(subject, MAX_SUBJECT_BYTES).to_string(), + body: clip(body, MAX_BODY_BYTES).to_string(), + refs: parse_deco(deco), + }) +} + +fn signature(name: &str, email: &str, at: &str) -> Signature { + Signature { + name: name.to_string(), + email: email.to_string(), + // A commit whose `%aI` will not parse is not worth dropping the commit + // over; it loses its timestamp and keeps everything else. + at: parse_iso8601(at).unwrap_or(OffsetTs { + unix: 0, + offset_minutes: 0, + }), + } +} + +fn is_hex_oid(text: &str) -> bool { + (4..=64).contains(&text.len()) && text.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Truncates to at most `max` bytes without splitting a character. +fn clip(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut end = max; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +/// `%D` with `--decorate=full`: `HEAD -> refs/heads/main, tag: refs/tags/v1, +/// refs/remotes/origin/main`. +fn parse_deco(text: &str) -> Vec { + let mut out = Vec::new(); + for piece in text.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + let (is_head, name) = match piece.strip_prefix("HEAD -> ") { + Some(rest) => (true, rest.trim()), + None => (false, piece), + }; + // `--decorate=full` spells refnames out in full but still marks tags + // with a `tag: ` prefix, so both have to come off. + let name = name.strip_prefix("tag: ").unwrap_or(name).trim(); + if name == "HEAD" { + out.push(RefDeco { + kind: RefKind::Head, + full: "HEAD".to_string(), + short: "HEAD".to_string(), + is_head: true, + upstream: None, + }); + continue; + } + if let Some(deco) = ref_deco(name, is_head) { + out.push(deco); + } + } + out +} + +fn ref_deco(full: &str, is_head: bool) -> Option { + let (kind, short) = if let Some(rest) = full.strip_prefix("refs/heads/") { + (RefKind::LocalBranch, rest) + } else if let Some(rest) = full.strip_prefix("refs/remotes/") { + (RefKind::RemoteBranch, rest) + } else if let Some(rest) = full.strip_prefix("refs/tags/") { + (RefKind::Tag, rest) + } else { + (RefKind::Other, full.strip_prefix("refs/").unwrap_or(full)) + }; + if short.is_empty() { + return None; + } + Some(RefDeco { + kind, + full: full.to_string(), + short: short.to_string(), + is_head, + upstream: None, + }) +} + +/// Parses the output of the `for-each-ref` invocation [`REF_FORMAT`] belongs +/// to, keyed by the commit each ref ultimately points at. +pub fn parse_refs(stdout: &[u8]) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + let text = String::from_utf8_lossy(stdout); + for line in text.lines().take(MAX_REFS) { + let mut fields = line.split(FIELD_SEP as char); + let object = fields.next().unwrap_or_default().trim(); + 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. `%(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().unwrap_or_default().trim(); + let head = fields.next().unwrap_or_default().trim(); + let _kind = fields.next(); + let peeled = fields.next().unwrap_or_default().trim(); + // `%(*objectname)` is empty unless this is an annotated tag. When it is + // not, the chip belongs on the commit rather than on the tag object, + // which is the only reason the field is in the format. + let target = if peeled.is_empty() { object } else { peeled }; + if full.is_empty() || !is_hex_oid(target) { + continue; + } + 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); + } + } + out +} + +pub fn for_each_ref(host: &dyn Host, root: &Path) -> HashMap> { + let count = format!("--count={MAX_REFS}"); + let args = [ + "for-each-ref", + "--sort=-committerdate", + &count, + REF_FORMAT, + "refs/heads", + "refs/remotes", + "refs/tags", + ]; + match host.git(root, &args) { + Ok(out) if out.success() => parse_refs(&out.stdout), + _ => HashMap::new(), + } +} + +/// 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).commits.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); + // A dropped record here is a >1 MiB *pathname* — losing that one row from + // a commit's file list is the same answer `MAX_COMMIT_FILES` already gives + // for lists that are merely long. + let _ = 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 +/// commits every time, and any ref that moves between two pages shifts the +/// window so page two no longer continues page one. Re-walking is O(n) either +/// way, the layout is deterministic, so a larger page reproduces the previous +/// one as its prefix and nothing on screen moves. +pub fn load_page( + host: &dyn Host, + root: &Path, + scope: &GraphScope, + count: usize, +) -> Option { + let count = count.clamp(1, MAX_GRAPH_COMMITS); + let revs = scope_revs(host, root, scope); + if revs.is_empty() { + // An unborn HEAD. Not a failure: there is simply no history yet. + return Some(CommitPage { + commits: Vec::new(), + rows: Vec::new(), + max_lanes: 0, + scope: scope.clone(), + requested: count, + complete: true, + truncated_lanes: false, + open_lanes: Vec::new(), + }); + } + + let n = count.to_string(); + let mut args = vec![ + "-c", + // Verifying signatures on every commit costs more than everything else + // in this command put together, and the graph never shows the result. + "log.showSignature=false", + "log", + // Not `--date-order`: the layout needs every parent to come after all + // of its children, and dates do not guarantee that. A rebase or a + // cherry-pick across timezones is enough to invert a pair. + "--topo-order", + "--decorate=full", + "--no-color", + LOG_PRETTY, + "-n", + &n, + ]; + args.extend(revs.iter().map(String::as_str)); + + // Buffered rather than streamed on purpose. `Host::git_lines` splits on + // newlines, which would chop these RS-delimited records apart and leave the + // caller to glue them back together, and `Host::git` is byte-exact over the + // control protocol's base64. 5000 commits is a few MB, which that carries + // fine. If it ever stops being fine the fix is a raw byte stream on `Host`, + // and that one does need a control protocol bump. + let out = host.git(root, &args).ok()?; + if !out.success() { + return None; + } + let parsed = parse_log(&out.stdout); + let mut commits = parsed.commits; + // "End of history" needs both halves: git answered with fewer than asked + // for, *and* the parse read everything git answered with. A stream cut at + // `MAX_LOG_BYTES` also has fewer commits than `count` — calling that + // complete would freeze paging on a truncated graph. + let complete = !parsed.truncated && commits.len() < count; + + let page: Vec<(Oid, SmallVec<[Oid; 2]>)> = commits + .iter() + .map(|c| (c.oid.clone(), c.parents.clone())) + .collect(); + let mut alloc = LaneAlloc::new(); + let mut rows = Vec::with_capacity(page.len()); + alloc.push(&page, &mut rows); + let max_lanes = rows.iter().map(row_span).max().unwrap_or(0); + + let mut by_oid = for_each_ref(host, root); + for commit in &mut commits { + if let Some(extra) = by_oid.remove(&commit.oid) { + for deco in extra { + if !commit.refs.iter().any(|r| r.full == deco.full) { + commit.refs.push(deco); + } + } + } + // Highest priority first, so a row that has space for one chip can take + // the first and count the rest. + commit.refs.sort_by(|a, b| { + b.is_head + .cmp(&a.is_head) + .then_with(|| b.kind.cmp(&a.kind)) + .then_with(|| a.short.cmp(&b.short)) + }); + } + + Some(CommitPage { + commits, + rows, + max_lanes, + scope: scope.clone(), + requested: count, + complete, + truncated_lanes: alloc.truncated(), + open_lanes: alloc.open_lanes(), + }) +} + +/// The revs to walk for a scope. +/// +/// Every symbolic name resolves to a sha first. Paging re-runs the walk with a +/// larger `-n`, and a symbolic `HEAD` or branch name would let a commit pushed +/// between the two runs change where page two starts — the second page would +/// no longer be a superset of the first, which is the one thing paging here +/// relies on. (`--all` cannot be pinned; that scope accepts the reflow.) +/// A name that no longer resolves — a deleted branch, an unborn HEAD — simply +/// contributes nothing, which reads as "no history" rather than as a failure. +fn scope_revs(host: &dyn Host, root: &Path, scope: &GraphScope) -> Vec { + match scope { + GraphScope::Head => rev(host, root, "HEAD^{commit}").into_iter().collect(), + GraphScope::All => vec!["--all".to_string()], + GraphScope::Refs(refs) => refs + .iter() + // A refname cannot begin with `-`, so anything that does is + // either a mistake or an option smuggled in through a scope. + .filter(|r| !r.is_empty() && !r.starts_with('-')) + .filter_map(|r| rev(host, root, &format!("{r}^{{commit}}"))) + .collect(), + GraphScope::HeadAndUpstream => { + let mut revs = Vec::new(); + if let Some(head) = rev(host, root, "HEAD^{commit}") { + revs.push(head); + } + if let Some(upstream) = rev(host, root, "@{upstream}^{commit}") + && !revs.contains(&upstream) + { + revs.push(upstream); + } + revs + } + } +} + +fn rev(host: &dyn Host, root: &Path, spec: &str) -> Option { + let out = super::git(host, root, &["rev-parse", "--verify", "--quiet", spec])?; + let sha = out.trim(); + is_hex_oid(sha).then(|| sha.to_string()) +} + +/// `%aI` is strict ISO 8601: `2026-08-09T14:03:11+08:00`, or `Z` for UTC. +/// +/// Hand-rolled because the workspace carries neither `chrono` nor `time`, and +/// thirty lines of arithmetic is a poor reason to add a dependency tree to a +/// crate the headless server also builds. +fn parse_iso8601(text: &str) -> Option { + let b = text.as_bytes(); + if !text.is_ascii() || b.len() < 19 { + return None; + } + if b[4] != b'-' || b[7] != b'-' || b[13] != b':' || b[16] != b':' { + return None; + } + if b[10] != b'T' && b[10] != b't' && b[10] != b' ' { + return None; + } + let year: i64 = text[0..4].parse().ok()?; + let month: u32 = text[5..7].parse().ok()?; + let day: u32 = text[8..10].parse().ok()?; + let hour: i64 = text[11..13].parse().ok()?; + let minute: i64 = text[14..16].parse().ok()?; + // 60 is a leap second, which git will never emit but which is legal. + let second: i64 = text[17..19].parse().ok()?; + if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) { + return None; + } + if hour > 23 || minute > 59 || second > 60 { + return None; + } + let offset_minutes = parse_offset(&text[19..])?; + let unix = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second + - i64::from(offset_minutes) * 60; + Some(OffsetTs { + unix, + offset_minutes, + }) +} + +fn parse_offset(text: &str) -> Option { + if text.is_empty() || text == "Z" || text == "z" { + return Some(0); + } + let (sign, rest) = match text.as_bytes()[0] { + b'+' => (1, &text[1..]), + b'-' => (-1, &text[1..]), + _ => return None, + }; + let (hours, minutes) = match rest.len() { + 5 if rest.as_bytes()[2] == b':' => (&rest[0..2], &rest[3..5]), + 4 => (&rest[0..2], &rest[2..4]), + 2 => (rest, "0"), + _ => return None, + }; + let hours: i32 = hours.parse().ok()?; + let minutes: i32 = minutes.parse().ok()?; + if hours > 23 || minutes > 59 { + return None; + } + Some(sign * (hours * 60 + minutes)) +} + +fn days_in_month(year: i64, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29, + 2 => 28, + _ => 0, + } +} + +/// Days since 1970-01-01, by Hinnant's era algorithm. Shifting the year to +/// start in March is what keeps the leap rules out of the code: a 400-year era +/// is exactly 146097 days, so every correction collapses into a division. +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let shifted = i64::from((month + 9) % 12); + let day_of_year = (153 * shifted + 2) / 5 + i64::from(day) - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::git::test_support::{PINS, pin_repo_config}; + + fn commit(sha: &str, parents: &[&str]) -> (Oid, SmallVec<[Oid; 2]>) { + ( + sha.to_string(), + parents.iter().map(|p| p.to_string()).collect(), + ) + } + + fn lay_out(page: &[(Oid, SmallVec<[Oid; 2]>)]) -> Vec { + let mut rows = Vec::new(); + LaneAlloc::new().push(page, &mut rows); + rows + } + + fn pass_at(lane: Lane) -> Edge { + Edge::Pass { lane, color: lane } + } + + fn in_at(lane: Lane) -> Edge { + Edge::In { + from: lane, + color: lane, + } + } + + fn out_at(lane: Lane) -> Edge { + Edge::Out { + to: lane, + color: lane, + } + } + + /// Lanes crossing the row's top edge, sorted. + fn top(row: &GraphRow) -> Vec { + let mut lanes: Vec = row + .edges + .iter() + .filter_map(|e| match *e { + Edge::Pass { lane, .. } => Some(lane), + Edge::In { from, .. } => Some(from), + Edge::Out { .. } => None, + }) + .collect(); + lanes.sort_unstable(); + lanes + } + + /// Lanes crossing the row's bottom edge, sorted and folded. + /// + /// Folded, because one lane can legally carry a `Pass` *and* an `Out`: a + /// merge whose second parent already has a lane reserved by another child + /// sends its `Out` onto that lane, joining the line rather than opening a + /// second one. Below the row that is a single line in a single colour (an + /// `Out`'s colour is its lane), so the cut sees one line — which the + /// assertions below verify before folding. + fn bottom(row: &GraphRow) -> Vec { + let mut lanes: Vec = row + .edges + .iter() + .filter_map(|e| match *e { + Edge::Pass { lane, .. } => Some(lane), + Edge::Out { to, .. } => Some(to), + Edge::In { .. } => None, + }) + .collect(); + lanes.sort_unstable(); + lanes.dedup(); + lanes + } + + /// The property the whole layout rests on: at any horizontal cut through + /// the graph a lane carries at most one visible line, and what leaves a + /// row's bottom is exactly what enters the next row's top. Together those + /// two mean colour-by-lane can never put two visible lines in one colour. + /// + /// "Visible" carries the one nuance: an `Out` may land on a lane a `Pass` + /// already crosses — a join, see [`bottom`] — and that pair is one line. + /// Two `Pass`es or two `Out`s on one lane are still bugs. + fn assert_lanes_line_up(rows: &[GraphRow]) { + for (i, row) in rows.iter().enumerate() { + let once = |mut lanes: Vec| { + lanes.sort_unstable(); + let len = lanes.len(); + lanes.dedup(); + assert_eq!(lanes.len(), len, "row {i} doubles up a lane: {row:?}"); + }; + once(top(row)); + once( + row.edges + .iter() + .filter_map(|e| match *e { + Edge::Pass { lane, .. } => Some(lane), + _ => None, + }) + .collect(), + ); + once( + row.edges + .iter() + .filter_map(|e| match *e { + Edge::Out { to, .. } => Some(to), + _ => None, + }) + .collect(), + ); + } + for (i, pair) in rows.windows(2).enumerate() { + assert_eq!( + bottom(&pair[0]), + top(&pair[1]), + "row {i} does not hand its lanes to row {}", + i + 1 + ); + } + } + + #[test] + fn a_linear_chain_stays_in_one_lane() { + let page = [commit("a", &["b"]), commit("b", &["c"]), commit("c", &[])]; + let rows = lay_out(&page); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r.node == 0 && r.color == 0)); + assert_eq!(rows[0].edges.as_slice(), [out_at(0)]); + assert_eq!(rows[1].edges.as_slice(), [in_at(0), out_at(0)]); + assert_eq!(rows[2].edges.as_slice(), [in_at(0)]); + assert_eq!(rows[2].parents, 0, "the last one is a root"); + assert_lanes_line_up(&rows); + } + + #[test] + fn a_fork_and_a_merge_open_and_close_one_lane() { + let page = [ + commit("m", &["a", "b"]), + commit("a", &["base"]), + commit("b", &["base"]), + commit("base", &[]), + ]; + let rows = lay_out(&page); + + assert_eq!(rows[0].node, 0); + assert_eq!(rows[0].parents, 2); + assert_eq!( + rows[0].edges.as_slice(), + [out_at(0), out_at(1)], + "the merge leaves on its own lane and on a fresh one" + ); + assert_eq!(rows[1].edges.as_slice(), [pass_at(1), in_at(0), out_at(0)]); + assert_eq!(rows[2].node, 1, "the second parent kept the lane it opened"); + assert_eq!(rows[3].node, 0); + assert_eq!( + rows[3].edges.as_slice(), + [in_at(0), in_at(1)], + "both sides come back together at the base" + ); + assert_eq!(rows[3].parents, 0); + assert_lanes_line_up(&rows); + } + + #[test] + fn an_octopus_merge_leaves_on_one_lane_per_parent() { + let page = [ + commit("m", &["p1", "p2", "p3"]), + commit("p1", &[]), + commit("p2", &[]), + commit("p3", &[]), + ]; + let rows = lay_out(&page); + + assert_eq!(rows[0].parents, 3); + assert_eq!(rows[0].edges.as_slice(), [out_at(0), out_at(1), out_at(2)]); + assert_eq!( + rows.iter().map(|r| r.node).collect::>(), + [0, 0, 1, 2] + ); + assert_lanes_line_up(&rows); + } + + #[test] + fn a_parent_outside_the_window_leaves_its_lane_open() { + let page = [commit("a", &["b"])]; + let mut alloc = LaneAlloc::new(); + let mut rows = Vec::new(); + alloc.push(&page, &mut rows); + + assert_eq!(alloc.open_lanes(), [0], "b is below the page boundary"); + assert_eq!(alloc.width(), 1); + assert!(!alloc.truncated()); + } + + #[test] + fn two_independent_roots_end_their_own_lanes() { + let page = [ + commit("a", &["a1"]), + commit("b", &["b1"]), + commit("a1", &[]), + commit("b1", &[]), + ]; + let mut alloc = LaneAlloc::new(); + let mut rows = Vec::new(); + alloc.push(&page, &mut rows); + + assert_eq!( + rows.iter().map(|r| r.node).collect::>(), + [0, 1, 0, 1], + "the two histories never share a lane" + ); + assert_eq!(rows[2].parents, 0); + assert_eq!(rows[3].parents, 0); + assert!( + alloc.open_lanes().is_empty(), + "both lanes died at their root" + ); + assert_lanes_line_up(&rows); + } + + #[test] + fn a_dead_lane_is_reused_without_two_live_lines_sharing_it() { + let page = [ + commit("a", &["a1"]), + commit("b", &["b1"]), + commit("b1", &[]), + commit("c", &["c1"]), + ]; + let rows = lay_out(&page); + + assert_eq!(rows[1].node, 1); + assert_eq!(rows[3].node, 1, "the freed lane was handed to the new tip"); + assert!( + !bottom(&rows[2]).contains(&1), + "the old line is gone from the band before the new one starts" + ); + assert!(!top(&rows[3]).contains(&1), "the new tip has nothing above"); + assert_lanes_line_up(&rows); + } + + #[test] + fn splitting_a_page_in_two_lays_out_identically() { + let page = [ + commit("m", &["a", "b"]), + commit("a", &["base"]), + commit("b", &["base"]), + commit("t", &["q"]), + commit("base", &["p"]), + commit("p", &["q"]), + commit("q", &[]), + ]; + + let whole = lay_out(&page); + + let mut alloc = LaneAlloc::new(); + let mut paged = Vec::new(); + alloc.push(&page[..3], &mut paged); + let first_page = paged.clone(); + alloc.push(&page[3..], &mut paged); + + assert_eq!(first_page, whole[..3], "the first page is a prefix"); + assert_eq!(paged, whole, "and loading more never re-flows it"); + assert_lanes_line_up(&whole); + } + + #[test] + fn a_second_parent_avoids_the_lane_this_row_just_freed() { + let page = [ + commit("t0", &["c"]), + commit("t1", &["x"]), + commit("t2", &["c"]), + commit("c", &["p0", "p1"]), + ]; + let rows = lay_out(&page); + + let merge = &rows[3]; + assert_eq!(merge.node, 0); + assert_eq!(top(merge), [0, 1, 2], "two lines land here, one passes by"); + assert!( + !merge.edges.contains(&out_at(2)), + "lane 2 just ended here; leaving on it would draw a V: {merge:?}" + ); + assert!(merge.edges.contains(&out_at(3))); + assert_lanes_line_up(&rows); + } + + /// The commonest merge topology of all: "merge main into topic after main + /// advanced". The merge's second parent (`c`) already has a lane reserved + /// by another child (`x`), so the merge's `Out` *joins* that lane instead + /// of opening a second one to the same commit — the row legally carries a + /// `Pass` and an `Out` on lane 0, one line below the cut, not two. + #[test] + fn a_second_parent_joins_a_line_another_child_opened() { + let page = [ + commit("x", &["c"]), + commit("m", &["a", "c"]), + commit("a", &["c"]), + commit("c", &[]), + ]; + let rows = lay_out(&page); + + assert_eq!(rows[0].edges.as_slice(), [out_at(0)]); + let merge = &rows[1]; + assert_eq!(merge.node, 1, "the merge tips a lane of its own"); + assert_eq!( + merge.edges.as_slice(), + [pass_at(0), out_at(1), out_at(0)], + "first parent inherits the node's lane; the second joins lane 0" + ); + assert_eq!( + rows[3].edges.as_slice(), + [in_at(0), in_at(1)], + "both lines still converge on the shared parent" + ); + assert_lanes_line_up(&rows); + } + + #[test] + fn more_parents_than_lanes_truncates_instead_of_panicking() { + let parents: Vec = (0..40).map(|i| format!("p{i}")).collect(); + let refs: Vec<&str> = parents.iter().map(String::as_str).collect(); + let mut page = vec![commit("m", &refs)]; + page.extend(parents.iter().map(|p| commit(p, &[]))); + + let mut alloc = LaneAlloc::new(); + let mut rows = Vec::new(); + alloc.push(&page, &mut rows); + + assert!(alloc.truncated()); + assert_eq!(rows[0].parents, 40); + assert!( + rows.iter().all(|r| r.node < MAX_LANES), + "every row stayed inside the lane budget" + ); + assert!(rows.iter().flat_map(bottom).all(|lane| lane < MAX_LANES)); + } + + const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const SHA_C: &str = "cccccccccccccccccccccccccccccccccccccccc"; + + fn record(fields: &[&str]) -> String { + format!("\x1e{}", fields.join("\x1f")) + } + + fn one(oid: &str, parents: &str, deco: &str, subject: &str, body: &str) -> String { + record(&[ + oid, + parents, + "Ada", + "ada@example.com", + "2026-08-09T14:03:11+08:00", + "Grace", + "grace@example.com", + "2026-08-09T15:00:00+08:00", + deco, + subject, + body, + ]) + } + + /// A parse that could not read everything must say so — `load_page` turns + /// `truncated` into `complete: false`, and a truncated graph that claimed + /// to be the end of history would freeze paging on it forever. + #[test] + fn a_stream_the_parse_cannot_finish_is_never_called_complete() { + // One record past MAX_RECORD: dropped whole by the splitter. + let huge_body = "x".repeat(super::super::MAX_RECORD + 1); + let stream = [ + one(SHA_A, SHA_B, "", "kept", ""), + one(SHA_B, "", "", "monster", &huge_body), + ] + .concat(); + let parsed = parse_log(stream.as_bytes()); + assert_eq!(parsed.commits.len(), 1, "the readable record survives"); + assert!(parsed.truncated); + + // Cumulative bytes past MAX_LOG_BYTES: the tail is clipped. + let body = "y".repeat(512 * 1024); + let stream: String = (0..40) + .map(|i| one(&format!("{i:040}"), "", "", "big", &body)) + .collect(); + let parsed = parse_log(stream.as_bytes()); + assert!(parsed.commits.len() < 40); + assert!(parsed.truncated); + + let parsed = parse_log(one(SHA_A, "", "", "small", "fine").as_bytes()); + assert!(!parsed.truncated, "an ordinary stream is read in full"); + } + + #[test] + fn a_multi_line_body_survives_the_record_split() { + let stream = [ + one( + SHA_A, + SHA_B, + "", + "first", + "line one\nline two\n\nline four\n", + ), + one(SHA_B, "", "", "second", ""), + ] + .join("\n"); + let commits = parse_log(stream.as_bytes()).commits; + + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].summary, "first"); + assert_eq!(commits[0].body, "line one\nline two\n\nline four"); + assert_eq!(commits[0].author.name, "Ada"); + assert_eq!(commits[0].committer.email, "grace@example.com"); + assert_eq!(commits[1].body, ""); + assert_eq!(commits[1].parents.len(), 0); + assert!(!commits[0].is_merge()); + } + + #[test] + fn a_merge_records_both_parents() { + let stream = one(SHA_A, &format!("{SHA_B} {SHA_C}"), "", "merge", ""); + let commits = parse_log(stream.as_bytes()).commits; + + assert_eq!(commits[0].parents.as_slice(), [SHA_B, SHA_C]); + assert!(commits[0].is_merge()); + assert_eq!(commits[0].short(), "aaaaaaa"); + } + + #[test] + fn decorations_map_to_their_ref_kinds() { + let deco = "HEAD -> refs/heads/main, refs/remotes/origin/main, tag: refs/tags/v1.0"; + let stream = one(SHA_A, "", deco, "subject", ""); + let refs = parse_log(stream.as_bytes()).commits.remove(0).refs; + + assert_eq!(refs.len(), 3); + assert_eq!(refs[0].kind, RefKind::LocalBranch); + assert_eq!(refs[0].short, "main"); + assert_eq!(refs[0].full, "refs/heads/main"); + assert!(refs[0].is_head); + assert_eq!(refs[1].kind, RefKind::RemoteBranch); + assert_eq!(refs[1].short, "origin/main"); + assert!(!refs[1].is_head); + assert_eq!(refs[2].kind, RefKind::Tag); + assert_eq!(refs[2].short, "v1.0"); + + let detached = one(SHA_A, "", "HEAD, refs/tags/v2", "subject", ""); + let refs = parse_log(detached.as_bytes()).commits.remove(0).refs; + assert_eq!(refs[0].kind, RefKind::Head); + assert!(refs[0].is_head); + } + + #[test] + fn a_unit_separator_inside_a_body_does_not_shift_fields() { + let body = "before\x1fafter\x1fand\x1fmore"; + let stream = one(SHA_A, "", "", "subject", body); + let commits = parse_log(stream.as_bytes()).commits; + + assert_eq!( + commits[0].summary, "subject", + "the subject is still field 10" + ); + assert_eq!(commits[0].body, body, "the body swallowed the extra ones"); + } + + #[test] + fn a_record_that_does_not_start_with_a_sha_is_dropped() { + let stream = [ + record(&["not a sha", "", "who", "", "", "", "", "", "", "junk", ""]), + one(SHA_A, "", "", "real", ""), + record(&[SHA_B, "only two fields"]), + ] + .join("\n"); + let commits = parse_log(stream.as_bytes()).commits; + + assert_eq!(commits.len(), 1, "{commits:?}"); + assert_eq!(commits[0].summary, "real"); + } + + #[test] + fn iso_8601_offsets_and_leap_days_parse() { + assert_eq!( + parse_iso8601("1970-01-01T00:00:00Z"), + Some(OffsetTs { + unix: 0, + offset_minutes: 0 + }) + ); + assert_eq!( + parse_iso8601("1970-01-01T00:00:00+00:00"), + Some(OffsetTs { + unix: 0, + offset_minutes: 0 + }) + ); + // Same instant, written from two sides of the planet. + assert_eq!( + parse_iso8601("2026-08-09T14:03:11+08:00"), + Some(OffsetTs { + unix: 1_786_255_391, + offset_minutes: 480 + }) + ); + assert_eq!( + parse_iso8601("2026-08-09T02:03:11-04:00"), + Some(OffsetTs { + unix: 1_786_255_391, + offset_minutes: -240 + }) + ); + assert_eq!( + parse_iso8601("2026-08-09T11:33:11+05:30").map(|t| t.unix), + Some(1_786_255_391) + ); + assert_eq!( + parse_iso8601("2024-02-29T00:00:00Z").map(|t| t.unix), + Some(1_709_164_800), + "2024 is a leap year" + ); + assert_eq!( + parse_iso8601("2000-02-29T00:00:00Z").map(|t| t.unix), + Some(951_782_400), + "and so is 2000, the four-hundred-year exception" + ); + assert_eq!(parse_iso8601("2023-02-29T00:00:00Z"), None); + assert_eq!(parse_iso8601("1900-02-29T00:00:00Z"), None); + assert_eq!(parse_iso8601("2026-13-01T00:00:00Z"), None); + assert_eq!(parse_iso8601("2026-08-09T24:00:00Z"), None); + assert_eq!(parse_iso8601("nope"), None); + assert_eq!(parse_iso8601("2026-08-09T14:03:11 08:00"), None); + } + + #[test] + fn an_oversized_subject_and_body_are_cut_on_a_char_boundary() { + let subject = "提".repeat(400); + let body = "交".repeat(4000); + let stream = one(SHA_A, "", "", &subject, &body); + let commit = parse_log(stream.as_bytes()).commits.remove(0); + + assert_eq!( + commit.summary.len(), + MAX_SUBJECT_BYTES - MAX_SUBJECT_BYTES % 3, + "cut back to the last whole character" + ); + assert!(commit.summary.chars().all(|c| c == '提')); + assert_eq!(commit.body.len(), MAX_BODY_BYTES - MAX_BODY_BYTES % 3); + assert!(commit.body.chars().all(|c| c == '交')); + } + + #[test] + fn an_annotated_tag_lands_on_the_commit_not_the_tag_object() { + let tag_object = "1111111111111111111111111111111111111111"; + let lines = [ + format!( + "{SHA_A}\x1frefs/heads/main\x1fmain\x1frefs/remotes/origin/main\x1f*\x1fcommit\x1f" + ), + format!("{tag_object}\x1frefs/tags/v9\x1fv9\x1f\x1f \x1ftag\x1f{SHA_B}"), + format!("{SHA_C}\x1frefs/remotes/origin/dev\x1forigin/dev\x1f\x1f \x1fcommit\x1f"), + ]; + let by_oid = parse_refs(lines.join("\n").as_bytes()); + + assert_eq!(by_oid[SHA_A][0].kind, RefKind::LocalBranch); + assert!(by_oid[SHA_A][0].is_head, "the `*` column marks HEAD"); + assert!( + !by_oid.contains_key(tag_object), + "the tag object itself is never a graph row" + ); + assert_eq!(by_oid[SHA_B][0].kind, RefKind::Tag); + assert_eq!(by_oid[SHA_B][0].short, "v9"); + 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()).commits; + 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")); + } + + // ----- against a real repository ------------------------------------- + // + // Both of these build the history they assert on rather than reading the + // tty7 checkout they were compiled in. CI clones shallow (one commit) and + // checks a pull request out as a detached HEAD with no local branch, so + // "how deep is the history" and "is there a branch" are facts about the + // runner, not about this code — and a source tarball has no repository at + // all. Owning the fixture is what lets the counts below be exact. + + struct Scratch(std::path::PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn scratch(name: &str) -> Option { + // The pid keeps two concurrent `cargo test` runs off each other's + // fixture, since the directory is wiped on the way in. + let dir = std::env::temp_dir().join(format!("tty7-scm-log-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok()?; + Some(Scratch(dir)) + } + + /// Runs git with the identity, signing and line-ending settings pinned: a + /// CI runner has no `user.name` at all and would refuse to commit, a + /// developer may have signing turned on globally, and Git for Windows has + /// `core.autocrlf=true` in its system config. + fn run(host: &dyn Host, cwd: &Path, args: &[&str]) -> bool { + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + host.git(cwd, &full).map(|o| o.success()).unwrap_or(false) + } + + /// `git init` on a branch named here rather than left to whatever + /// `init.defaultBranch` says, with the same pins written into the + /// repository so the code under test reads them too. `false` means there is + /// no git on this machine, which is not a failure. + fn init_repo(host: &dyn Host, repo: &Path) -> bool { + if !run(host, repo, &["init", "--quiet"]) { + return false; + } + assert!(pin_repo_config(repo)); + // Not `init -b`: that is git 2.28+, and the branch name is asserted on. + assert!(run( + host, + repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + true + } + + fn commit_file(host: &dyn Host, repo: &Path, path: &str, body: &str, message: &str) { + std::fs::write(repo.join(path), body).unwrap(); + assert!(run(host, repo, &["add", "--", path])); + assert!(run(host, repo, &["commit", "--quiet", "-m", message])); + } + + #[test] + fn a_real_repository_answers_for_one_commit_and_its_files() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("one-commit") else { + return; + }; + let repo = &scratch.0; + if !init_repo(&*host, repo) { + return; // no git on this machine + } + + std::fs::write(repo.join("kept.txt"), "one\n").unwrap(); + std::fs::write(repo.join("moved.txt"), "a\nb\nc\nd\ne\nf\ng\nh\n").unwrap(); + std::fs::write(repo.join("gone.txt"), "bye\n").unwrap(); + assert!(run(&*host, repo, &["add", "--", "."])); + assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); + + // One commit with four different things in it, because the join of + // `--numstat` and `--name-status` is only exercised by a commit that + // touches more than one path, and the rename is the record whose two + // halves are shaped differently in the two streams. + std::fs::write(repo.join("kept.txt"), "one\ntwo\n").unwrap(); + std::fs::write(repo.join("added.txt"), "new\n").unwrap(); + assert!(run(&*host, repo, &["mv", "moved.txt", "renamed.txt"])); + assert!(run(&*host, repo, &["rm", "--quiet", "--", "gone.txt"])); + assert!(run(&*host, repo, &["add", "--", "."])); + assert!(run( + &*host, + repo, + &["commit", "--quiet", "-m", "feat: four ways at once"] + )); + // A second branch, so `local_branches` has to return more than the one + // HEAD happens to be on. + assert!(run(&*host, repo, &["branch", "side"])); + + let page = load_page(&*host, repo, &GraphScope::Head, 2) + .expect("a repository was just created here"); + let head = page.commits.first().expect("two commits were just made"); + + let shown = load_commit(&*host, repo, &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, repo, "-n"), None); + + let files = commit_files(&*host, repo, &head.oid).expect("HEAD touched something"); + assert!( + files.iter().all(|f| !f.path.is_empty()), + "an empty path means the join lost a record: {files:?}" + ); + let mut got: Vec<(&str, FileStatus)> = + files.iter().map(|f| (f.path.as_str(), f.status)).collect(); + got.sort_by_key(|(path, _)| *path); + assert_eq!( + got, + [ + ("added.txt", FileStatus::Added), + ("gone.txt", FileStatus::Deleted), + ("kept.txt", FileStatus::Modified), + ("renamed.txt", FileStatus::Renamed), + ], + "every path the commit touched, with the letter git gave it: {files:?}" + ); + + fn file<'a>(files: &'a [CommitFile], path: &str) -> &'a CommitFile { + files.iter().find(|f| f.path == path).unwrap() + } + // The whole reason the two commands are run separately: the counts come + // from `--numstat` and the letters from `--name-status`, so a row that + // has both is a row the join put back together. + assert_eq!( + ( + file(&files, "kept.txt").added, + file(&files, "kept.txt").removed + ), + (Some(1), Some(0)) + ); + assert_eq!( + ( + file(&files, "gone.txt").added, + file(&files, "gone.txt").removed + ), + (Some(0), Some(1)) + ); + let renamed = file(&files, "renamed.txt"); + assert_eq!(renamed.orig_path.as_deref(), Some("moved.txt")); + assert_eq!((renamed.added, renamed.removed), (Some(0), Some(0))); + assert!(files.iter().all(|f| !f.binary)); + + let branches = local_branches(&*host, repo); + assert_eq!(branches, ["main", "side"], "both of them, in refname order"); + assert!(branches.iter().all(|b| !b.starts_with("refs/heads/"))); + } + + /// The lane layout against a history with a shape, not a straight line: + /// + /// ```text + /// top main + /// merge + /// | \ + /// main2 side2 + /// main1 side1 + /// | / + /// root + /// ``` + /// + /// Seven commits, which is also what makes the paging assertion at the end + /// honest — a page of five cannot be the whole history. + #[test] + fn a_real_repository_lays_out_one_row_per_commit() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("layout") else { + return; + }; + let repo = &scratch.0; + if !init_repo(&*host, repo) { + return; // no git on this machine + } + + commit_file(&*host, repo, "root.txt", "0\n", "root"); + assert!(run(&*host, repo, &["checkout", "--quiet", "-b", "side"])); + commit_file(&*host, repo, "side.txt", "1\n", "side one"); + commit_file(&*host, repo, "side.txt", "2\n", "side two"); + assert!(run(&*host, repo, &["checkout", "--quiet", "main"])); + commit_file(&*host, repo, "main.txt", "1\n", "main one"); + commit_file(&*host, repo, "main.txt", "2\n", "main two"); + // The two branches touch different files, so this merges clean. + assert!(run( + &*host, + repo, + &[ + "merge", + "--quiet", + "--no-ff", + "--no-edit", + "-m", + "merge side", + "side" + ] + )); + commit_file(&*host, repo, "main.txt", "3\n", "after the merge"); + + let page = + load_page(&*host, repo, &GraphScope::Head, 30).expect("a repository was just created"); + + assert_eq!(page.rows.len(), page.commits.len()); + assert_eq!( + page.commits.len(), + 7, + "the root, two on the side branch, two on main, the merge, and the one on top" + ); + assert!(page.complete, "thirty asked for, seven exist"); + assert!( + page.max_lanes >= 2, + "a branch that forks and merges back needs a second lane: {page:?}" + ); + assert!(!page.truncated_lanes); + assert!(page.rows.iter().all(|r| r.node < page.max_lanes)); + assert!(page.commits.iter().all(|c| is_hex_oid(&c.oid))); + assert!( + page.commits + .iter() + .all(|c| c.author.at.unix > 1_600_000_000), + "every commit got a real timestamp" + ); + assert_lanes_line_up(&page.rows); + + // Row *i* is commit *i*, which is only worth checking where the two + // could come apart: the merge is the one commit with two parents, and + // its row is the one that sends a line to each of them. + let merges: Vec = page + .commits + .iter() + .enumerate() + .filter(|(_, c)| c.parents.len() == 2) + .map(|(i, _)| i) + .collect(); + assert_eq!(merges.len(), 1, "one merge in the fixture"); + let row = &page.rows[merges[0]]; + assert_eq!(row.parents, 2, "the row agrees with the commit beside it"); + assert_eq!( + row.edges + .iter() + .filter(|e| matches!(e, Edge::Out { .. })) + .count(), + 2, + "the merge row leaves for both parents: {row:?}" + ); + + let page = load_page(&*host, repo, &GraphScope::HeadAndUpstream, 5).unwrap(); + assert_eq!(page.commits.len(), 5); + assert!(!page.complete, "five of the seven is not the whole history"); + } +} diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs new file mode 100644 index 00000000..73536ea1 --- /dev/null +++ b/crates/tty7-core/src/core/git/mod.rs @@ -0,0 +1,682 @@ +//! Everything tty7 knows about git, split by question: +//! +//! - this file — how a `git` process is *run* (and its output re-assembled) +//! - [`status`] — what the working tree looks like right now +//! - [`diff`] — what a particular patch looks like +//! - [`log`] — what the history looks like, and how to lay it out in lanes +//! - [`ops`] — how to *change* the repository +//! +//! Nothing here depends on gpui: the headless `tty7-server` answers the same +//! questions for a remote workspace that `LocalHost` answers for this machine, +//! and the conformance suite holds the two to the same behaviour. +pub mod diff; +pub mod log; +pub mod ops; +pub mod status; + +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::host::{Host, Output}; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitStatus { + pub branch: String, + pub added: u32, + pub removed: u32, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RepoSnapshot { + pub root: PathBuf, + pub home: PathBuf, + pub branch: String, + pub counts: Option<(u32, u32)>, +} + +pub fn probe(host: &dyn Host, cwd: &Path) -> Option { + let paths = git( + host, + cwd, + &[ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ], + )?; + let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); + let root = PathBuf::from(lines.next()?); + let home = repo_home(&root, lines.next(), lines.next()); + let branch = branch_name(host, cwd)?; + Some(RepoSnapshot { + home, + root, + branch, + counts: diff_numstat(host, cwd), + }) +} + +pub(crate) fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { + let (Some(git_dir), Some(common)) = (git_dir, common_dir) else { + return root.to_path_buf(); + }; + if git_dir == common { + return root.to_path_buf(); + } + let common = Path::new(common); + match (common.file_name(), common.parent()) { + (Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(), + _ => common.to_path_buf(), + } +} + +pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option { + if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + let name = out.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?; + let sha = sha.trim(); + (!sha.is_empty()).then(|| sha.to_string()) +} + +fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> { + let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?; + let mut added = 0u32; + let mut removed = 0u32; + for line in out.lines() { + let mut fields = line.split('\t'); + if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { + added += n; + } + if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { + removed += n; + } + } + Some((added, removed)) +} + +pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { + let out = host.git(cwd, args).ok()?; + if !out.success() { + return None; + } + String::from_utf8(out.stdout).ok() +} + +pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { + git_output_with_env(cwd, args, &[]) +} + +/// `git_output` plus extra environment. +/// +/// What `LocalHost` passes is the no-prompt set: git and ssh have to fail +/// rather than block on a credential prompt nobody is watching. It goes on +/// every call, not just `fetch`/`pull`/`push` — a read path never prompts, so +/// carrying it there costs nothing, and a remote workspace only inherits it +/// because the far side's `LocalHost` applies the same rule to a request that +/// arrived over the wire with no "this one is a network op" bit on it. +/// +/// A `None` value removes the variable instead of setting it. +pub fn git_output_with_env( + cwd: &Path, + args: &[&str], + env: &[(&str, Option<&str>)], +) -> io::Result { + if !cwd.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("git cwd does not exist: {}", cwd.display()), + )); + } + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(cwd) + .args(args) + // Keeps `git status` from refreshing and writing back `.git/index`. + // Beyond the obvious (never dirty a repo just by looking at it), this is + // what stops the SCM panel's own probes from waking the `.git` watcher + // that schedules them — the read path is provably write-free. Do not + // drop it. + .env("GIT_OPTIONAL_LOCKS", "0") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in env { + match value { + Some(v) => cmd.env(key, v), + None => cmd.env_remove(key), + }; + } + let out = crate::core::proc::hide_console(&mut cmd).output()?; + Ok(Output { + status: out.status.code(), + stdout: out.stdout, + stderr: out.stderr, + }) +} + +pub fn git_stream( + cwd: &Path, + args: &[&str], + mut on_chunk: impl FnMut(&[u8]) -> bool, +) -> io::Result> { + if !cwd.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("git cwd does not exist: {}", cwd.display()), + )); + } + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(cwd) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("git stdout was not piped"))?; + + let mut buf = vec![0u8; 64 * 1024]; + let mut read_err = None; + loop { + match stdout.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if !on_chunk(&buf[..n]) { + break; + } + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => { + read_err = Some(e); + break; + } + } + } + drop(stdout); + let status = child.wait()?; + match read_err { + Some(e) => Err(e), + None => Ok(status.code()), + } +} + +#[derive(Default)] +pub struct LineSplitter { + tail: Vec, + dropped: usize, +} + +pub const MAX_LINE: usize = 1024 * 1024; + +impl LineSplitter { + pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) { + let mut rest = chunk; + while let Some(nl) = rest.iter().position(|b| *b == b'\n') { + let (line, after) = rest.split_at(nl); + if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE { + on_line(&trim_cr(line)); + } else { + self.keep(line); + let joined = std::mem::take(&mut self.tail); + let dropped = std::mem::take(&mut self.dropped); + emit(&joined, dropped, &mut on_line); + } + rest = &after[1..]; + } + self.keep(rest); + } + + pub fn finish(self, mut on_line: impl FnMut(&str)) { + if !self.tail.is_empty() || self.dropped > 0 { + emit(&self.tail, self.dropped, &mut on_line); + } + } + + fn keep(&mut self, bytes: &[u8]) { + let room = MAX_LINE.saturating_sub(self.tail.len()); + let take = room.min(bytes.len()); + self.tail.extend_from_slice(&bytes[..take]); + self.dropped += bytes.len() - take; + } +} + +/// Splits a byte stream on an arbitrary separator, handing out whole records. +/// +/// [`LineSplitter`]'s sibling, for the two git formats that are *not* newline +/// delimited: `--porcelain=v2 -z` (NUL) and `log --pretty` with an ASCII record +/// separator. Records come out as `&[u8]` rather than `&str` because a path in +/// a `-z` status is raw bytes and need not be UTF-8 at all — deciding what to +/// do about that belongs to the parser, not to the splitter. +pub struct RecordSplitter { + sep: u8, + tail: Vec, + /// The record being assembled overran [`MAX_RECORD`] and is now being + /// discarded up to its separator. + discarding: bool, + dropped: usize, +} + +pub const MAX_RECORD: usize = 1024 * 1024; + +impl RecordSplitter { + pub fn new(sep: u8) -> RecordSplitter { + RecordSplitter { + sep, + tail: Vec::new(), + discarding: false, + dropped: 0, + } + } + + pub fn push(&mut self, chunk: &[u8], mut on_record: impl FnMut(&[u8])) { + let mut rest = chunk; + while let Some(at) = rest.iter().position(|b| *b == self.sep) { + let (record, after) = rest.split_at(at); + // An overlong record is dropped whole, never delivered cut short: + // a truncated record still parses — a commit body cut mid-way + // reads as the real message — and a wrong record is worse than a + // missing one the caller is told about. + if self.discarding { + self.discarding = false; + self.dropped += 1; + } else if self.tail.is_empty() { + // The common case — a whole record inside one chunk — is + // borrowed straight from the input, no copy. + if record.len() <= MAX_RECORD { + on_record(record); + } else { + self.dropped += 1; + } + } else { + self.keep(record); + if self.discarding { + self.discarding = false; + self.dropped += 1; + } else { + let joined = std::mem::take(&mut self.tail); + on_record(&joined); + } + } + rest = &after[1..]; + } + self.keep(rest); + } + + /// Emits a trailing record only if one was actually started — unlike + /// lines, well-formed `-z` output ends *with* a separator, so the common + /// case here is emitting nothing. Returns how many records were dropped + /// whole for overrunning [`MAX_RECORD`]; non-zero means the parse is + /// incomplete and the caller must not present it as the full answer. + #[must_use] + pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) -> usize { + if self.discarding { + self.dropped += 1; + } else if !self.tail.is_empty() { + let joined = std::mem::take(&mut self.tail); + on_record(&joined); + } + self.dropped + } + + fn keep(&mut self, bytes: &[u8]) { + if self.discarding { + return; + } + if self.tail.len() + bytes.len() > MAX_RECORD { + // Free what was buffered too — nobody will ever see this record. + self.tail.clear(); + self.discarding = true; + return; + } + self.tail.extend_from_slice(bytes); + } +} + +fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) { + if dropped == 0 { + on_line(&trim_cr(line)); + return; + } + let mut text = String::from_utf8_lossy(line).into_owned(); + text.push_str(&format!( + " …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]" + )); + on_line(&text); +} + +fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> { + let line = match line.last() { + Some(b'\r') => &line[..line.len() - 1], + _ => line, + }; + String::from_utf8_lossy(line) +} + +/// What [`status`], [`diff`], [`log`] and [`ops`] all need to drive a scratch +/// repository the same way. One copy here rather than four that drift. +#[cfg(test)] +pub(crate) mod test_support { + use std::path::Path; + + /// The `-c` overrides a test's own git invocations carry. + /// + /// `user.name`/`user.email` because a CI runner has neither and git refuses + /// to commit without them, and `commit.gpgsign` because a developer may + /// have signing on globally. The two line-ending settings are here for the + /// same reason: Git for Windows ships `core.autocrlf=true` in its *system* + /// config, so a fixture built with LF and read back is a fixture whose + /// bytes depend on which machine ran the test. + pub(crate) const PINS: [&str; 10] = [ + "-c", + "user.name=tty7 test", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + ]; + + /// The same settings, written into `/.git/config`. + /// + /// [`PINS`] only reaches the commands the *test* runs. The code under test + /// runs its own git — `run_op`'s `checkout --`, `probe_status`, + /// `probe_diff` — with no overrides at all, and rightly so: it must obey + /// the repository the user actually has. So the line-ending rules have to + /// live in the repository, where every git that opens it will read them, + /// and repository config outranks the system config that put + /// `core.autocrlf=true` there. + /// + /// Call this straight after `git init`, before anything is written or + /// checked out, so no blob is ever created under the other rules. + pub(crate) fn pin_repo_config(repo: &Path) -> bool { + PINS.chunks(2).all(|pair| { + let Some((key, value)) = pair[1].split_once('=') else { + return false; + }; + super::git_output(repo, &["config", key, value]).is_ok_and(|out| out.success()) + }) + } + + /// A path in the one spelling two halves of an assertion can agree on. + /// + /// They do not naturally agree. Anything that came out of `git rev-parse` + /// is in git's dialect — forward slashes, no extended-length prefix, even + /// on Windows — while the expected side is usually built from + /// `fs::canonicalize`, which on Windows answers `\\?\C:\…`. Both name the + /// same directory and the Win32 file APIs take either, so the code under + /// test is right to pass git's answer straight through; it is only the + /// comparison that has to pick a spelling. On unix this is the identity. + /// + /// Note this normalises the *spelling*, not the path: equality stays exact. + pub(crate) fn one_spelling(p: &Path) -> String { + let slashed = p.to_string_lossy().replace('\\', "/"); + match slashed.strip_prefix("//?/") { + Some(bare) => bare.to_string(), + None => slashed, + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::one_spelling; + use super::*; + + fn h() -> crate::host::SharedHost { + crate::host::local::LocalHost::new() + } + + /// The mapping [`test_support::one_spelling`] promises, proven on literals + /// rather than on whatever this machine's temp directory happens to be — + /// a developer on unix never sees either of the Windows shapes, which is + /// exactly how a comparison against `fs::canonicalize` reached Windows CI + /// unnoticed in the first place. + #[test] + fn one_spelling_folds_the_two_ways_windows_writes_a_path() { + assert_eq!( + one_spelling(Path::new(r"\\?\C:\Users\x\repo\.git")), + "C:/Users/x/repo/.git", + "the extended-length prefix goes, and the separators match git's" + ); + assert_eq!( + one_spelling(Path::new(r"C:\Users\x\repo\.git")), + "C:/Users/x/repo/.git", + "a plain Windows path lands on the same spelling" + ); + assert_eq!( + one_spelling(Path::new("C:/Users/x/repo/.git")), + "C:/Users/x/repo/.git", + "what git itself answers is already in that spelling" + ); + assert_eq!( + one_spelling(Path::new("/private/var/t/repo/.git")), + "/private/var/t/repo/.git", + "on unix it is the identity, which is why this never fired locally" + ); + } + + #[test] + fn line_splitter_rejoins_across_chunks() { + let mut split = LineSplitter::default(); + let mut got = Vec::new(); + split.push(b"alpha\nbe", |l| got.push(l.to_string())); + assert_eq!(got, ["alpha"], "only the complete line so far"); + split.push(b"ta\ngamma\n", |l| got.push(l.to_string())); + split.finish(|l| got.push(l.to_string())); + assert_eq!(got, ["alpha", "beta", "gamma"]); + } + + #[test] + fn line_splitter_handles_crlf_and_a_missing_final_newline() { + let mut split = LineSplitter::default(); + let mut got = Vec::new(); + split.push(b"one\r\ntwo\r\nthree", |l| got.push(l.to_string())); + split.finish(|l| got.push(l.to_string())); + assert_eq!(got, ["one", "two", "three"]); + } + + #[test] + fn line_splitter_replaces_invalid_utf8() { + let mut split = LineSplitter::default(); + let mut got = Vec::new(); + split.push(b"caf\xe9\n", |l| got.push(l.to_string())); + split.finish(|l| got.push(l.to_string())); + assert_eq!(got.len(), 1); + assert!(got[0].starts_with("caf"), "{:?}", got[0]); + } + + #[test] + fn record_splitter_rejoins_across_chunks_and_emits_a_trailing_record() { + let mut split = RecordSplitter::new(0); + let mut got = Vec::new(); + split.push(b"alpha\0be", |r| got.push(r.to_vec())); + assert_eq!(got, [b"alpha".to_vec()], "only the complete record so far"); + split.push(b"ta\0gamma", |r| got.push(r.to_vec())); + // Well-formed `-z` output ends with a separator; a trailing record + // without one still comes out at `finish`. + assert_eq!(split.finish(|r| got.push(r.to_vec())), 0); + assert_eq!( + got, + [b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()] + ); + } + + /// An overlong record is dropped whole and *counted* — delivered cut + /// short it would still parse, and a commit body cut mid-way reads as the + /// real message. + #[test] + fn record_splitter_drops_an_absurd_record_whole_and_says_so() { + let mut split = RecordSplitter::new(0); + let mut got = Vec::new(); + let huge = vec![b'x'; MAX_RECORD + 5_000]; + split.push(b"before\0", |r| got.push(r.to_vec())); + for piece in huge.chunks(64 * 1024) { + split.push(piece, |r| got.push(r.to_vec())); + } + split.push(b"\0after\0", |r| got.push(r.to_vec())); + let dropped = split.finish(|r| got.push(r.to_vec())); + + assert_eq!(dropped, 1); + assert_eq!( + got, + [b"before".to_vec(), b"after".to_vec()], + "no truncated ghost between the two, and the next record survives" + ); + + // A single-chunk oversized record takes the borrow fast path and must + // be counted the same way. + let mut split = RecordSplitter::new(0); + let mut got: Vec> = Vec::new(); + let mut one = vec![b'y'; MAX_RECORD + 1]; + one.push(0); + one.extend_from_slice(b"tail\0"); + split.push(&one, |r| got.push(r.to_vec())); + assert_eq!(split.finish(|r| got.push(r.to_vec())), 1); + assert_eq!(got, [b"tail".to_vec()]); + } + + /// A stream that *ends* mid-way through an oversized record still reports + /// the drop. + #[test] + fn record_splitter_counts_a_truncated_trailing_record() { + let mut split = RecordSplitter::new(0); + let mut got: Vec> = Vec::new(); + split.push(&vec![b'z'; MAX_RECORD + 1], |r| got.push(r.to_vec())); + assert_eq!(split.finish(|r| got.push(r.to_vec())), 1); + assert!(got.is_empty()); + } + + #[test] + fn line_splitter_caps_one_absurd_line() { + let mut split = LineSplitter::default(); + let mut got = Vec::new(); + let huge = vec![b'x'; MAX_LINE + 5_000]; + split.push(b"before\n", |l| got.push(l.to_string())); + for piece in huge.chunks(64 * 1024) { + split.push(piece, |l| got.push(l.to_string())); + } + split.push(b"\nafter\n", |l| got.push(l.to_string())); + split.finish(|l| got.push(l.to_string())); + + assert_eq!( + got.len(), + 3, + "{:?}", + got.iter().map(|l| l.len()).collect::>() + ); + assert_eq!(got[0], "before"); + assert_eq!(got[2], "after", "the next line is unaffected"); + assert!( + got[1].starts_with(&"x".repeat(1000)), + "the kept prefix is the real content" + ); + assert!( + got[1].contains("5000 more bytes"), + "the cut says how much went: {}", + &got[1][got[1].len() - 80..] + ); + assert!( + got[1].len() < MAX_LINE + 200, + "nothing past the cap was retained" + ); + } + + #[test] + fn line_splitter_caps_a_final_unterminated_line() { + let mut split = LineSplitter::default(); + let mut got = Vec::new(); + split.push(&vec![b'y'; MAX_LINE + 7], |l| got.push(l.to_string())); + split.finish(|l| got.push(l.to_string())); + assert_eq!(got.len(), 1); + assert!(got[0].contains("7 more bytes"), "{}", got[0]); + } + + #[test] + fn streaming_and_buffered_reads_agree() { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + let args = ["log", "--oneline", "-n", "40"]; + let Ok(code) = git_stream(here, &args, |_| true) else { + return; + }; + if code != Some(0) { + return; + } + let mut streamed = Vec::new(); + let mut split = LineSplitter::default(); + git_stream(here, &args, |chunk| { + split.push(chunk, |l| streamed.push(l.to_string())); + true + }) + .unwrap(); + split.finish(|l| streamed.push(l.to_string())); + + let buffered = git_output(here, &args).unwrap(); + let expected: Vec = String::from_utf8_lossy(&buffered.stdout) + .lines() + .map(str::to_string) + .collect(); + assert_eq!(streamed, expected); + assert!(!streamed.is_empty(), "this repo has commits"); + } + + #[test] + fn non_repo_is_none() { + let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz"); + let _ = std::fs::create_dir_all(&dir); + assert_eq!(probe(&*h(), &dir), None); + } + + #[test] + fn missing_path_is_none() { + assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None); + } + + #[test] + fn own_repo_has_a_branch_and_root() { + let here = env!("CARGO_MANIFEST_DIR"); + if let Some(snap) = probe(&*h(), Path::new(here)) { + assert!(!snap.branch.is_empty()); + assert!(Path::new(here).starts_with(&snap.root)); + } + } + #[test] + fn repo_home_resolves_worktree_layouts() { + let root = Path::new("/repo/.wt/feat"); + + assert_eq!( + repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")), + PathBuf::from("/repo") + ); + assert_eq!( + repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")), + PathBuf::from("/repo") + ); + assert_eq!( + repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")), + PathBuf::from("/bare.git") + ); + assert_eq!( + repo_home(root, Some("/repo/.git"), None), + root.to_path_buf() + ); + assert_eq!(repo_home(root, None, None), root.to_path_buf()); + } +} diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs new file mode 100644 index 00000000..fe883838 --- /dev/null +++ b/crates/tty7-core/src/core/git/ops.rs @@ -0,0 +1,1809 @@ +//! Changing the repository. +//! +//! Every argv is built by [`GitOp::commands`], a pure function — that is where +//! the pathspec rules are enforced and where the tests point. `run_op` only +//! executes what it is handed and turns a failure into something the UI can +//! act on. +//! +//! Confirmation of destructive operations belongs to the UI, not here: +//! `run_op` has to stay callable from a flow that already confirmed once, and +//! from a test. What this module offers instead is [`GitOp::destructive`], the +//! policy datum the UI gates on. + +use std::path::{Path, PathBuf}; + +use super::status::{HeadState, RepoPath}; +use crate::host::Host; + +/// One argv can only carry so many paths before it hits `E2BIG` (~256 KiB on +/// macOS), so a big stage is split into several calls. +pub const MAX_PATHSPECS_PER_CALL: usize = 200; + +/// …and only so many *bytes*. The binding limit is not macOS's 256 KiB but +/// Windows' `CreateProcess`, which caps the whole command line at 32,767 +/// UTF-16 units — 200 deep-tree paths at 200+ characters each sail past it. +/// Sized with room for the prefix and the per-argument quoting the Windows +/// join adds. +pub const MAX_PATHSPEC_BYTES_PER_CALL: usize = 24 * 1024; + +/// Long enough for a push over a slow link. Only applied to network operations +/// — the local path has no deadline at all. +pub const GIT_NETWORK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600); + +/// How long a non-network write may take before the client stops waiting. +/// +/// Sized for a pre-commit hook that runs a linter or a test suite, not for an +/// interactive query: a remote server never times a job out — it runs it to +/// completion — so a deadline shorter than the job only *misreports* failure +/// while the commit lands anyway, and the offered re-run doubles it. +pub const GIT_WRITE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); + +/// What the user stands to lose. Purely advisory data for the UI's gate. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Destructive { + LosesWorktreeEdits, + LosesUntrackedFiles, + LosesCommits, + RewritesHistory, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ResetMode { + Soft, + Mixed, + Hard, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PullMode { + FfOnly, + Rebase, + Merge, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum GitOp { + Stage { + paths: Vec, + }, + StageAll, + Unstage { + paths: Vec, + }, + UnstageAll, + /// `git checkout --` on tracked files. + DiscardWorktree { + paths: Vec, + }, + /// `git clean` on untracked ones. + DiscardUntracked { + paths: Vec, + directories: bool, + }, + Commit { + message: String, + amend: bool, + signoff: bool, + no_verify: bool, + /// Stage every tracked change first (`-a`), for "Commit All". + all: bool, + }, + CheckoutBranch { + name: String, + }, + CheckoutDetached { + rev: String, + }, + CreateBranch { + name: String, + start: Option, + checkout: bool, + }, + DeleteBranch { + name: String, + force: bool, + }, + CherryPick { + rev: String, + mainline: bool, + no_commit: bool, + }, + Revert { + rev: String, + mainline: bool, + }, + Reset { + rev: String, + mode: ResetMode, + }, + Stash { + message: Option, + include_untracked: bool, + }, + Fetch { + remote: Option, + prune: bool, + }, + Pull { + mode: PullMode, + }, + Push { + remote: String, + /// The branch to update *on the remote*. The refspec sent is + /// `HEAD:`, never the bare name: a bare name means a *local* + /// branch, and a branch tracking a differently-named upstream — `feat` + /// created from `origin/main` — would push stale local `main` instead + /// of the branch the user is on. + branch: String, + /// First push of a new branch: `-u`. + set_upstream: bool, + force_with_lease: bool, + }, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitOpOutcome { + pub op: &'static str, + pub stdout: String, + /// Non-empty on success too — remote hints and hook output land here. + pub stderr: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GitOpErrorKind { + NotARepo, + /// The path is not UTF-8 and so cannot be sent as a pathspec. + UnrepresentablePath, + InvalidArgument, + DirtyWorktree, + Conflict, + NothingToCommit, + HookRejected, + LockHeld, + AuthRequired, + NetworkUnreachable, + NonFastForward, + Timeout, + Spawn, + Other, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitOpError { + pub op: &'static str, + pub kind: GitOpErrorKind, + /// One line for the notification. + pub message: String, + /// Full stderr, for the details disclosure. + pub detail: String, + /// The argv that failed. Authentication has no answer inside a GUI, so the + /// notification offers to re-run this in a pane — tty7 is a terminal, and a + /// real tty can take a password, a hardware key, or a host-key prompt. + pub rerun_argv: Vec, + pub cwd: PathBuf, +} + +impl GitOp { + /// The stable short name used in errors and telemetry. + pub fn label(&self) -> &'static str { + match self { + GitOp::Stage { .. } | GitOp::StageAll => "stage", + GitOp::Unstage { .. } | GitOp::UnstageAll => "unstage", + GitOp::DiscardWorktree { .. } | GitOp::DiscardUntracked { .. } => "discard", + GitOp::Commit { .. } => "commit", + GitOp::CheckoutBranch { .. } | GitOp::CheckoutDetached { .. } => "checkout", + GitOp::CreateBranch { .. } => "branch", + GitOp::DeleteBranch { .. } => "branch-delete", + GitOp::CherryPick { .. } => "cherry-pick", + GitOp::Revert { .. } => "revert", + GitOp::Reset { .. } => "reset", + GitOp::Stash { .. } => "stash", + GitOp::Fetch { .. } => "fetch", + GitOp::Pull { .. } => "pull", + GitOp::Push { .. } => "push", + } + } + + pub fn destructive(&self) -> Option { + Some(match self { + GitOp::DiscardWorktree { .. } => Destructive::LosesWorktreeEdits, + GitOp::DiscardUntracked { .. } => Destructive::LosesUntrackedFiles, + GitOp::DeleteBranch { .. } => Destructive::LosesCommits, + // The stronger of the two truths: a hard reset clobbers worktree + // edits *and* — pointed at an older commit — drops commits off + // the branch. The dialog has to warn about the worse one. + GitOp::Reset { + mode: ResetMode::Hard, + .. + } => Destructive::LosesCommits, + GitOp::Commit { amend: true, .. } => Destructive::RewritesHistory, + GitOp::Push { + force_with_lease: true, + .. + } => Destructive::RewritesHistory, + _ => return None, + }) + } + + /// Whether this reaches the network, and so needs the long deadline. + pub fn is_network(&self) -> bool { + matches!( + self, + GitOp::Fetch { .. } | GitOp::Pull { .. } | GitOp::Push { .. } + ) + } + + /// Every path this operation names, for validation and for deciding + /// which caches to invalidate. + pub fn paths(&self) -> &[super::status::RepoPath] { + match self { + GitOp::Stage { paths } + | GitOp::Unstage { paths } + | GitOp::DiscardWorktree { paths } + | GitOp::DiscardUntracked { paths, .. } => paths, + _ => &[], + } + } + + /// Every argv this operation runs, in order, relative to the repository + /// root. More than one only because a long path list has to be split. + /// + /// Deliberately the old spelling throughout: `checkout -- ` and + /// `reset HEAD -- `, never `git restore`. `restore` arrived in git + /// 2.23 (2019), is still documented as EXPERIMENTAL, and has had its + /// behaviour adjusted across releases; the two older forms have not moved + /// in over a decade. tty7's whole point is that a remote host behaves like + /// the local one, and ancient dev boxes are real. (The honest floor is + /// git 1.8.5, not older: every path-carrying op spells its pathspecs + /// `:(literal)`, which is where that magic arrived — CentOS 7's 1.8.3 + /// fails those with a clean "Invalid pathspec magic" rather than doing + /// anything wrong.) A version fork here would have to be tested twice + /// forever. + /// + /// So there is no version probing at all. The one case that genuinely + /// needs a different command is an unborn HEAD, and that needs no probe + /// either: `head` is a parameter, which is what keeps this function pure + /// and makes the argv table testable on its own. + pub fn commands(&self, head: &HeadState) -> Vec> { + match self { + GitOp::Stage { paths } => batched(&["add"], &pathspecs(paths)), + GitOp::StageAll => vec![argv(&["add", "-A", "--", "."])], + GitOp::Unstage { paths } => batched(unstage_prefix(head), &pathspecs(paths)), + GitOp::UnstageAll => batched(unstage_prefix(head), &[".".to_string()]), + GitOp::DiscardWorktree { paths } => batched(&["checkout"], &pathspecs(paths)), + GitOp::DiscardUntracked { paths, directories } => { + let force = if *directories { "-fd" } else { "-f" }; + batched(&["clean", force, "-q"], &pathspecs(paths)) + } + GitOp::Commit { + message, + amend, + signoff, + no_verify, + all, + } => { + let mut out = vec!["commit".to_string()]; + if *amend { + out.push("--amend".into()); + } + if *all { + out.push("-a".into()); + } + if *signoff { + out.push("--signoff".into()); + } + if *no_verify { + out.push("--no-verify".into()); + } + if message.is_empty() && *amend { + // Amending only to fold in more files: keep the message + // that is already there rather than clearing it. + out.push("--no-edit".into()); + } else { + if message.is_empty() { + // A merge commit whose message the user cleared still + // has to be committable. + out.push("--allow-empty-message".into()); + } + out.push("-m".into()); + out.push(message.clone()); + } + vec![out] + } + // The trailing `--` forces ref interpretation: without it a name + // that no longer resolves (deleted out from under a stale branch + // list) but matches a tracked *file* falls back to a path + // checkout — which silently discards worktree edits to that file, + // under an op whose `destructive()` says nothing is at risk. + GitOp::CheckoutBranch { name } => vec![argv(&["checkout", name, "--"])], + GitOp::CheckoutDetached { rev } => vec![argv(&["checkout", "--detach", rev])], + GitOp::CreateBranch { + name, + start, + checkout, + } => { + let mut out = if *checkout { + argv(&["checkout", "-b", name]) + } else { + argv(&["branch", name]) + }; + if let Some(start) = start { + out.push(start.clone()); + } + vec![out] + } + GitOp::DeleteBranch { name, force } => { + vec![argv(&["branch", if *force { "-D" } else { "-d" }, name])] + } + GitOp::CherryPick { + rev, + mainline, + no_commit, + } => { + let mut out = vec!["cherry-pick".to_string()]; + if *mainline { + out.extend(argv(&["-m", "1"])); + } + if *no_commit { + out.push("-n".into()); + } + out.push(rev.clone()); + vec![out] + } + GitOp::Revert { rev, mainline } => { + let mut out = argv(&["revert", "--no-edit"]); + if *mainline { + out.extend(argv(&["-m", "1"])); + } + out.push(rev.clone()); + vec![out] + } + GitOp::Reset { rev, mode } => { + let mode = match mode { + ResetMode::Soft => "--soft", + ResetMode::Mixed => "--mixed", + ResetMode::Hard => "--hard", + }; + vec![argv(&["reset", mode, rev])] + } + GitOp::Stash { + message, + include_untracked, + } => { + let mut out = argv(&["stash", "push"]); + if *include_untracked { + out.push("-u".into()); + } + if let Some(message) = message { + out.push("-m".into()); + out.push(message.clone()); + } + vec![out] + } + GitOp::Fetch { remote, prune } => { + let mut out = vec!["fetch".to_string()]; + if let Some(remote) = remote { + out.push(remote.clone()); + } + if *prune { + out.push("--prune".into()); + } + vec![out] + } + GitOp::Pull { mode } => { + let mode = match mode { + PullMode::FfOnly => "--ff-only", + PullMode::Rebase => "--rebase", + PullMode::Merge => "--no-rebase", + }; + vec![argv(&["pull", mode])] + } + GitOp::Push { + remote, + branch, + set_upstream, + force_with_lease, + } => { + let mut out = vec!["push".to_string()]; + if *set_upstream { + out.push("-u".into()); + } + if *force_with_lease { + // The only forced push this module can produce. A bare + // `--force` overwrites whatever arrived since the last + // fetch with no way to notice; `--force-with-lease` turns + // that into a rejection. There is no flag for the other. + out.push("--force-with-lease".into()); + } + out.push(remote.clone()); + // `HEAD:` pins the source to the current branch. See the + // field's doc — a bare branch name names a local branch. + out.push(format!("HEAD:{branch}")); + vec![out] + } + } + } + + /// Everything that can be rejected before a process is spawned. + /// + /// The ref-name rules are a subset of `git check-ref-format`, applied in + /// process: forking git to ask about a name the user is still typing would + /// cost more than the check is worth, and the subset covers every character + /// a GUI can plausibly produce. + pub fn validate(&self) -> Result<(), GitOpError> { + if let Some(bad) = self.paths().iter().find(|p| p.pathspec().is_none()) { + return Err(self.reject( + GitOpErrorKind::UnrepresentablePath, + format!( + "\"{}\" is not valid UTF-8, so it cannot be sent to git as a pathspec", + bad.as_str() + ), + )); + } + match self { + GitOp::CheckoutBranch { name } | GitOp::DeleteBranch { name, .. } => { + self.check_branch(name)? + } + GitOp::CreateBranch { name, start, .. } => { + self.check_branch(name)?; + if let Some(start) = start { + self.check_rev("start point", start)?; + } + } + GitOp::CheckoutDetached { rev } + | GitOp::CherryPick { rev, .. } + | GitOp::Revert { rev, .. } + | GitOp::Reset { rev, .. } => self.check_rev("revision", rev)?, + GitOp::Push { remote, branch, .. } => { + self.check_rev("remote", remote)?; + // The full branch check, not just the rev one: the name lands + // in a `HEAD:` refspec, where a `:` would smuggle in a + // second refspec — and `:foo` alone *deletes* remote `foo`. + self.check_branch(branch)?; + } + GitOp::Fetch { + remote: Some(remote), + .. + } => self.check_rev("remote", remote)?, + _ => {} + } + Ok(()) + } + + fn check_branch(&self, name: &str) -> Result<(), GitOpError> { + const BAD: &[char] = &[' ', '\t', '~', '^', ':', '?', '*', '[', '\\']; + let reason = if name.is_empty() { + "a branch name is required" + } else if name.contains(BAD) || name.chars().any(char::is_control) { + "a branch name cannot contain a space, ~, ^, :, ?, *, [ or \\" + } else if name.contains("..") || name.contains("@{") { + "a branch name cannot contain .. or @{" + } else if name.starts_with('-') { + // Otherwise git reads it as an option, not a name. + "a branch name cannot start with -" + } else if name.ends_with(".lock") || name.ends_with('/') || name.ends_with('.') { + "a branch name cannot end with .lock, / or ." + } else { + return Ok(()); + }; + Err(self.reject(GitOpErrorKind::InvalidArgument, reason.to_string())) + } + + fn check_rev(&self, what: &str, rev: &str) -> Result<(), GitOpError> { + let reason = if rev.is_empty() { + format!("a {what} is required") + } else if rev.starts_with('-') { + // Same trap as a branch named `-f`: git would read it as an option. + format!("a {what} cannot start with -") + } else { + return Ok(()); + }; + Err(self.reject(GitOpErrorKind::InvalidArgument, reason)) + } + + fn reject(&self, kind: GitOpErrorKind, message: String) -> GitOpError { + GitOpError { + op: self.label(), + kind, + detail: message.clone(), + message, + // Nothing ran, so there is nothing to offer re-running. + rerun_argv: Vec::new(), + cwd: PathBuf::new(), + } + } +} + +fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|p| (*p).to_string()).collect() +} + +fn pathspecs(paths: &[RepoPath]) -> Vec { + // Unrepresentable paths are dropped rather than passed through lossily: + // `validate` is what turns them into an error, and a lossy rendering handed + // to `git clean` could match a *different* file. + paths.iter().filter_map(RepoPath::pathspec).collect() +} + +fn unstage_prefix(head: &HeadState) -> &'static [&'static str] { + if head.has_commits() { + &["reset", "-q", "HEAD"] + } else { + // There is no HEAD to reset against before the first commit — git + // fails outright — so the index entry is dropped instead. `-f`, + // because without it git refuses a file whose staged content differs + // from the file on disk (staged, then edited again) — and with + // `--cached` the worktree is never touched, so nothing is at risk. + &["rm", "--cached", "-r", "-q", "-f"] + } +} + +/// `prefix -- `, split so no single argv can hit `E2BIG` on unix or +/// the 32,767-unit command-line cap on Windows — by count *and* by bytes, +/// whichever fills first. +/// +/// The `--` is not optional: without it a file named `HEAD` reads as a rev and +/// one named `-f` reads as an option. +fn batched(prefix: &[&str], specs: &[String]) -> Vec> { + let mut out = Vec::new(); + let mut chunk: Vec = Vec::new(); + let mut bytes = 0usize; + let flush = |chunk: Vec, out: &mut Vec>| { + let mut call = argv(prefix); + call.push("--".into()); + call.extend(chunk); + out.push(call); + }; + for spec in specs { + // Room for the quotes and the space the Windows argv join adds. + let cost = spec.len() + 3; + if !chunk.is_empty() + && (chunk.len() >= MAX_PATHSPECS_PER_CALL || bytes + cost > MAX_PATHSPEC_BYTES_PER_CALL) + { + flush(std::mem::take(&mut chunk), &mut out); + bytes = 0; + } + bytes += cost; + chunk.push(spec.clone()); + } + if !chunk.is_empty() { + flush(chunk, &mut out); + } + out +} + +/// What a failure means, from git's own words. +/// +/// Substring matching on English output, checked in a fixed order because the +/// phrases overlap — a rejected push says both "Updates were rejected" and +/// "fetch first". git's porcelain messages are not a stable interface in +/// principle, but these particular strings have survived every release since +/// they were introduced, and the fallback is only a less specific notification. +pub fn classify(stderr: &str, status: Option) -> GitOpErrorKind { + let has = |needle: &str| stderr.contains(needle); + + if has("could not read Username") + || has("Authentication failed") + || has("Permission denied (publickey)") + || has("terminal prompts disabled") + || has("Host key verification failed") + { + GitOpErrorKind::AuthRequired + } else if has("Could not resolve host") + || has("Connection timed out") + // Both spellings on purpose: ssh capitalizes it ("Network is + // unreachable", straight from strerror) while curl folds it into a + // lower-case sentence, and git relays whichever it got verbatim. + || has("etwork is unreachable") + || has("Connection refused") + { + GitOpErrorKind::NetworkUnreachable + } else if has("non-fast-forward") + || has("Updates were rejected") + || has("fetch first") + || has("tip of your current branch is behind") + { + GitOpErrorKind::NonFastForward + } else if has("nothing to commit") || has("no changes added to commit") { + GitOpErrorKind::NothingToCommit + } else if has("local changes") && has("would be overwritten") { + GitOpErrorKind::DirtyWorktree + } else if has("CONFLICT") || has("Unmerged paths") || has("after resolving the conflicts") { + GitOpErrorKind::Conflict + } else if has("index.lock") || has("Another git process") { + GitOpErrorKind::LockHeld + } else if has("hook declined") || has("pre-commit hook") || has("hook exited") { + GitOpErrorKind::HookRejected + } else if has("not a git repository") { + GitOpErrorKind::NotARepo + } else if status.is_none() { + // No exit code at all: killed by a signal, so git never got to say why. + GitOpErrorKind::Spawn + } else { + GitOpErrorKind::Other + } +} + +fn fallback_message(kind: GitOpErrorKind) -> &'static str { + match kind { + GitOpErrorKind::NotARepo => "not a git repository", + GitOpErrorKind::UnrepresentablePath => "the path cannot be sent to git", + GitOpErrorKind::InvalidArgument => "invalid argument", + GitOpErrorKind::DirtyWorktree => "local changes would be overwritten", + GitOpErrorKind::Conflict => "the operation left conflicts to resolve", + GitOpErrorKind::NothingToCommit => "nothing to commit", + GitOpErrorKind::HookRejected => "a git hook rejected the operation", + GitOpErrorKind::LockHeld => "another git process is holding the index lock", + GitOpErrorKind::AuthRequired => "authentication is required", + GitOpErrorKind::NetworkUnreachable => "the remote could not be reached", + GitOpErrorKind::NonFastForward => "the remote has commits this branch does not", + GitOpErrorKind::Timeout => "the operation timed out", + GitOpErrorKind::Spawn => "git could not be run", + GitOpErrorKind::Other => "git reported an error", + } +} + +fn first_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).find(|l| !l.is_empty()) +} + +/// Run one operation to completion. +/// +/// Batches run in order and stop at the first non-zero exit: a half-applied +/// stage is recoverable, but continuing past a failure would bury the reason +/// under later output. +pub fn run_op( + host: &dyn Host, + root: &Path, + op: &GitOp, + head: &HeadState, +) -> Result { + op.validate()?; + + let label = op.label(); + let batches = op.commands(head); + let total = batches.len(); + let mut outcome = GitOpOutcome { + op: label, + stdout: String::new(), + stderr: String::new(), + }; + + for (index, batch) in batches.iter().enumerate() { + let borrowed: Vec<&str> = batch.iter().map(String::as_str).collect(); + let rerun = || { + std::iter::once("git".to_string()) + .chain(batch.iter().cloned()) + .collect::>() + }; + + // Every write gets an explicit deadline: the default `Git` request + // deadline is sized for interactive queries, and a remote server runs + // every job to completion regardless — so a commit whose hook outlives + // the client's patience would be reported failed and then land anyway. + // Network verbs get the long allowance, everything else the write one. + // The no-prompt environment is not this layer's business: `LocalHost` + // puts it on every call, on both sides of the wire. + let deadline = if op.is_network() { + GIT_NETWORK_DEADLINE + } else { + GIT_WRITE_DEADLINE + }; + let spawned = host.git_with_deadline(root, &borrowed, deadline); + let out = spawned.map_err(|err| GitOpError { + op: label, + // A deadline expiry is its own kind: "git could not be run" tells + // the user to check their install, when the truth is the job was + // running — and on a remote host may still be. + kind: match err.kind() { + std::io::ErrorKind::TimedOut => GitOpErrorKind::Timeout, + _ => GitOpErrorKind::Spawn, + }, + message: err.to_string(), + detail: err.to_string(), + rerun_argv: rerun(), + cwd: root.to_path_buf(), + })?; + + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + + if !out.success() { + // git splits its own reporting: "nothing to commit" goes to stdout + // while everything around it goes to stderr, so both are classified. + let both = format!("{stderr}\n{stdout}"); + let kind = classify(&both, out.status); + let mut message = first_line(&stderr) + .or_else(|| first_line(&stdout)) + .unwrap_or(fallback_message(kind)) + .to_string(); + if total > 1 { + let batch_no = index + 1; + message.push_str(&format!(" (batch {batch_no} of {total})")); + } + return Err(GitOpError { + op: label, + kind, + message, + // Both streams: a pull explains itself across the two, and + // showing only one buries half the reason. + detail: match (stderr.is_empty(), stdout.is_empty()) { + (false, false) => format!("{stderr}\n{stdout}"), + (false, true) => stderr, + _ => stdout, + }, + rerun_argv: rerun(), + cwd: root.to_path_buf(), + }); + } + + push_section(&mut outcome.stdout, &stdout); + push_section(&mut outcome.stderr, &stderr); + } + + Ok(outcome) +} + +fn push_section(buffer: &mut String, section: &str) { + if section.is_empty() { + return; + } + if !buffer.is_empty() { + buffer.push('\n'); + } + buffer.push_str(section); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::git::test_support::{PINS, pin_repo_config}; + + fn born() -> HeadState { + HeadState::Branch { + name: "main".into(), + oid: "abc1234".into(), + } + } + + fn unborn() -> HeadState { + HeadState::Unborn { + branch: "main".into(), + } + } + + fn p(text: &str) -> RepoPath { + RepoPath::from_bytes(text.as_bytes()) + } + + fn commit(message: &str) -> GitOp { + GitOp::Commit { + message: message.into(), + amend: false, + signoff: false, + no_verify: false, + all: false, + } + } + + /// One of every variant, for the sweeps that must hold across all of them. + fn every_op() -> Vec { + let paths = vec![p("a.txt")]; + let mut ops = vec![ + GitOp::Stage { + paths: paths.clone(), + }, + GitOp::StageAll, + GitOp::Unstage { + paths: paths.clone(), + }, + GitOp::UnstageAll, + GitOp::DiscardWorktree { + paths: paths.clone(), + }, + GitOp::CheckoutBranch { name: "dev".into() }, + GitOp::CheckoutDetached { rev: "abc".into() }, + GitOp::DeleteBranch { + name: "dev".into(), + force: true, + }, + GitOp::Reset { + rev: "HEAD~1".into(), + mode: ResetMode::Hard, + }, + GitOp::Pull { + mode: PullMode::Rebase, + }, + ]; + for directories in [false, true] { + ops.push(GitOp::DiscardUntracked { + paths: paths.clone(), + directories, + }); + } + for amend in [false, true] { + for all in [false, true] { + ops.push(GitOp::Commit { + message: "m".into(), + amend, + signoff: true, + no_verify: true, + all, + }); + } + } + for checkout in [false, true] { + ops.push(GitOp::CreateBranch { + name: "dev".into(), + start: Some("origin/main".into()), + checkout, + }); + } + for mainline in [false, true] { + ops.push(GitOp::CherryPick { + rev: "abc".into(), + mainline, + no_commit: true, + }); + ops.push(GitOp::Revert { + rev: "abc".into(), + mainline, + }); + } + for include_untracked in [false, true] { + ops.push(GitOp::Stash { + message: Some("wip".into()), + include_untracked, + }); + } + for prune in [false, true] { + ops.push(GitOp::Fetch { + remote: Some("origin".into()), + prune, + }); + } + for set_upstream in [false, true] { + for force_with_lease in [false, true] { + ops.push(GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream, + force_with_lease, + }); + } + } + ops + } + + #[test] + fn staging_wraps_every_path_in_a_literal_pathspec_after_a_separator() { + assert_eq!( + GitOp::Stage { + paths: vec![p("src/main.rs"), p("a[b].txt")], + } + .commands(&born()), + vec![vec![ + "add", + "--", + ":(literal)src/main.rs", + ":(literal)a[b].txt" + ]], + ); + assert_eq!( + GitOp::StageAll.commands(&born()), + vec![vec!["add", "-A", "--", "."]], + ); + } + + #[test] + fn unstaging_resets_against_head_once_the_repository_has_a_commit() { + assert_eq!( + GitOp::Unstage { + paths: vec![p("a[b].txt")], + } + .commands(&born()), + vec![vec!["reset", "-q", "HEAD", "--", ":(literal)a[b].txt"]], + ); + assert_eq!( + GitOp::UnstageAll.commands(&born()), + vec![vec!["reset", "-q", "HEAD", "--", "."]], + ); + } + + #[test] + fn unstaging_drops_the_index_entry_when_head_is_unborn() { + assert_eq!( + GitOp::Unstage { + paths: vec![p("x")], + } + .commands(&unborn()), + // `-f` because a staged-then-edited file otherwise refuses to + // unstage before the first commit; `--cached` keeps it worktree-safe. + vec![vec![ + "rm", + "--cached", + "-r", + "-q", + "-f", + "--", + ":(literal)x" + ]], + ); + assert_eq!( + GitOp::UnstageAll.commands(&unborn()), + vec![vec!["rm", "--cached", "-r", "-q", "-f", "--", "."]], + ); + } + + #[test] + fn discarding_picks_checkout_for_tracked_files_and_clean_for_the_rest() { + assert_eq!( + GitOp::DiscardWorktree { + paths: vec![p("a.txt")], + } + .commands(&born()), + vec![vec!["checkout", "--", ":(literal)a.txt"]], + ); + assert_eq!( + GitOp::DiscardUntracked { + paths: vec![p("a.txt")], + directories: false, + } + .commands(&born()), + vec![vec!["clean", "-f", "-q", "--", ":(literal)a.txt"]], + ); + assert_eq!( + GitOp::DiscardUntracked { + paths: vec![p("build")], + directories: true, + } + .commands(&born()), + vec![vec!["clean", "-fd", "-q", "--", ":(literal)build"]], + ); + } + + #[test] + fn a_long_path_list_is_split_into_batches_that_each_carry_the_separator() { + let paths: Vec = (0..MAX_PATHSPECS_PER_CALL + 1) + .map(|i| p(&format!("f{i}.txt"))) + .collect(); + let batches = GitOp::Stage { paths }.commands(&born()); + + assert_eq!(batches.len(), 2, "201 paths do not fit one argv"); + assert_eq!(batches[0].len(), 2 + MAX_PATHSPECS_PER_CALL); + assert_eq!(batches[1].len(), 3, "the remainder is a single path"); + for batch in &batches { + assert_eq!(batch[0], "add"); + assert_eq!(batch[1], "--", "every batch separates on its own"); + assert!(batch[2..].iter().all(|s| s.starts_with(":(literal)"))); + } + assert_eq!(batches[1][2], ":(literal)f200.txt"); + } + + /// The count cap alone is not enough: 200 deep-tree paths at 200+ + /// characters each sail past Windows' 32,767-unit command line. Bytes + /// split a batch before the count does. + #[test] + fn long_paths_split_a_batch_by_bytes_before_the_count_cap() { + let long = "d/".repeat(150) + "file.rs"; // ~300 bytes each + let paths: Vec = (0..MAX_PATHSPECS_PER_CALL).map(|_| p(&long)).collect(); + let batches = GitOp::Stage { paths }.commands(&born()); + + assert!(batches.len() > 1, "200 × ~300B has to split"); + for batch in &batches { + let bytes: usize = batch[2..].iter().map(|s| s.len() + 3).sum(); + assert!( + bytes <= MAX_PATHSPEC_BYTES_PER_CALL, + "batch of {bytes} bytes would overflow a Windows command line" + ); + assert!(batch.len() >= 3, "no batch goes out empty"); + } + let total: usize = batches.iter().map(|b| b.len() - 2).sum(); + assert_eq!(total, MAX_PATHSPECS_PER_CALL, "every path is still sent"); + } + + #[test] + fn a_file_named_head_or_dash_f_is_never_read_as_a_rev_or_an_option() { + for op in [ + GitOp::Stage { + paths: vec![p("HEAD"), p("-f")], + }, + GitOp::Unstage { + paths: vec![p("HEAD"), p("-f")], + }, + GitOp::DiscardWorktree { + paths: vec![p("HEAD"), p("-f")], + }, + GitOp::DiscardUntracked { + paths: vec![p("HEAD"), p("-f")], + directories: false, + }, + ] { + for head in [born(), unborn()] { + let batch = op.commands(&head).remove(0); + let sep = batch.iter().position(|a| a == "--").expect("a separator"); + assert_eq!( + &batch[sep + 1..], + [":(literal)HEAD", ":(literal)-f"], + "{batch:?}", + ); + } + } + } + + #[test] + fn a_path_that_is_not_utf8_is_refused_before_anything_runs() { + let bad = RepoPath::from_bytes(&[b'f', 0xff, b'.', b't', b'x', b't']); + assert!(bad.pathspec().is_none(), "the fixture must be lossy"); + + let err = GitOp::Stage { + paths: vec![p("ok.txt"), bad.clone()], + } + .validate() + .expect_err("a lossy path cannot be staged"); + assert_eq!(err.kind, GitOpErrorKind::UnrepresentablePath); + assert_eq!(err.op, "stage"); + assert!(err.rerun_argv.is_empty(), "nothing ran"); + + // `commands` still has to be total, and must not smuggle the lossy + // rendering through as if it were the real name. + assert_eq!( + GitOp::Stage { + paths: vec![p("ok.txt"), bad], + } + .commands(&born()), + vec![vec!["add", "--", ":(literal)ok.txt"]], + ); + } + + #[test] + fn commit_argv_carries_only_the_flags_it_was_asked_for() { + assert_eq!( + commit("hello").commands(&born()), + vec![vec!["commit", "-m", "hello"]], + ); + assert_eq!( + GitOp::Commit { + message: "hello".into(), + amend: true, + signoff: true, + no_verify: true, + all: true, + } + .commands(&born()), + vec![vec![ + "commit", + "--amend", + "-a", + "--signoff", + "--no-verify", + "-m", + "hello" + ]], + ); + } + + #[test] + fn an_amend_with_no_message_keeps_the_one_that_is_already_there() { + assert_eq!( + GitOp::Commit { + message: String::new(), + amend: true, + signoff: false, + no_verify: false, + all: false, + } + .commands(&born()), + vec![vec!["commit", "--amend", "--no-edit"]], + ); + } + + #[test] + fn an_empty_message_has_to_be_allowed_explicitly() { + assert_eq!( + commit("").commands(&born()), + vec![vec!["commit", "--allow-empty-message", "-m", ""]], + ); + } + + #[test] + fn branch_argv_says_which_of_the_four_shapes_it_is() { + assert_eq!( + GitOp::CheckoutBranch { + name: "feature".into(), + } + .commands(&born()), + // The trailing `--` keeps a stale branch name from falling back + // to a worktree-clobbering *path* checkout. + vec![vec!["checkout", "feature", "--"]], + ); + assert_eq!( + GitOp::CheckoutDetached { + rev: "abc1234".into(), + } + .commands(&born()), + vec![vec!["checkout", "--detach", "abc1234"]], + ); + assert_eq!( + GitOp::CreateBranch { + name: "feature".into(), + start: None, + checkout: true, + } + .commands(&born()), + vec![vec!["checkout", "-b", "feature"]], + ); + assert_eq!( + GitOp::CreateBranch { + name: "feature".into(), + start: Some("origin/main".into()), + checkout: false, + } + .commands(&born()), + vec![vec!["branch", "feature", "origin/main"]], + ); + assert_eq!( + GitOp::DeleteBranch { + name: "feature".into(), + force: false, + } + .commands(&born()), + vec![vec!["branch", "-d", "feature"]], + ); + assert_eq!( + GitOp::DeleteBranch { + name: "feature".into(), + force: true, + } + .commands(&born()), + vec![vec!["branch", "-D", "feature"]], + ); + } + + #[test] + fn history_argv_keeps_the_revision_last_so_options_cannot_swallow_it() { + assert_eq!( + GitOp::CherryPick { + rev: "abc".into(), + mainline: false, + no_commit: false, + } + .commands(&born()), + vec![vec!["cherry-pick", "abc"]], + ); + assert_eq!( + GitOp::CherryPick { + rev: "abc".into(), + mainline: true, + no_commit: true, + } + .commands(&born()), + vec![vec!["cherry-pick", "-m", "1", "-n", "abc"]], + ); + assert_eq!( + GitOp::Revert { + rev: "abc".into(), + mainline: false, + } + .commands(&born()), + vec![vec!["revert", "--no-edit", "abc"]], + ); + assert_eq!( + GitOp::Revert { + rev: "abc".into(), + mainline: true, + } + .commands(&born()), + vec![vec!["revert", "--no-edit", "-m", "1", "abc"]], + ); + for (mode, flag) in [ + (ResetMode::Soft, "--soft"), + (ResetMode::Mixed, "--mixed"), + (ResetMode::Hard, "--hard"), + ] { + assert_eq!( + GitOp::Reset { + rev: "HEAD~1".into(), + mode, + } + .commands(&born()), + vec![vec!["reset", flag, "HEAD~1"]], + ); + } + } + + #[test] + fn stash_argv_names_the_message_only_when_there_is_one() { + assert_eq!( + GitOp::Stash { + message: None, + include_untracked: false, + } + .commands(&born()), + vec![vec!["stash", "push"]], + ); + assert_eq!( + GitOp::Stash { + message: Some("wip".into()), + include_untracked: true, + } + .commands(&born()), + vec![vec!["stash", "push", "-u", "-m", "wip"]], + ); + } + + #[test] + fn network_argv_covers_fetch_pull_and_push() { + assert_eq!( + GitOp::Fetch { + remote: None, + prune: false, + } + .commands(&born()), + vec![vec!["fetch"]], + ); + assert_eq!( + GitOp::Fetch { + remote: Some("origin".into()), + prune: true, + } + .commands(&born()), + vec![vec!["fetch", "origin", "--prune"]], + ); + for (mode, flag) in [ + (PullMode::FfOnly, "--ff-only"), + (PullMode::Rebase, "--rebase"), + (PullMode::Merge, "--no-rebase"), + ] { + assert_eq!( + GitOp::Pull { mode }.commands(&born()), + vec![vec!["pull", flag]], + ); + } + assert_eq!( + GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream: false, + force_with_lease: false, + } + .commands(&born()), + // `HEAD:main`, not `main`: a bare name is a *local* branch, and a + // branch tracking a differently-named upstream would push the + // wrong one. + vec![vec!["push", "origin", "HEAD:main"]], + ); + assert_eq!( + GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream: true, + force_with_lease: true, + } + .commands(&born()), + vec![vec![ + "push", + "-u", + "--force-with-lease", + "origin", + "HEAD:main" + ]], + ); + } + + #[test] + fn no_operation_can_ever_produce_a_bare_force() { + for op in every_op() { + for head in [born(), unborn()] { + for batch in op.commands(&head) { + // The two sanctioned `-f`s: `clean` (that is the verb's + // whole meaning, and it is gated as destructive) and + // `rm --cached` (never touches the worktree). + let exempt = batch[0] == "clean" + || (batch[0] == "rm" && batch.iter().any(|a| a == "--cached")); + assert!( + !batch.iter().any(|a| a == "--force" || a == "-f" && !exempt), + "{:?} would force: {batch:?}", + op.label(), + ); + } + } + } + // …and the one lease-guarded form is still reachable. + assert!( + GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream: false, + force_with_lease: true, + } + .commands(&born())[0] + .iter() + .any(|a| a == "--force-with-lease"), + ); + } + + #[test] + fn every_operation_validates_and_produces_at_least_one_command() { + for op in every_op() { + op.validate() + .unwrap_or_else(|e| panic!("{}: {}", op.label(), e.message)); + for head in [born(), unborn()] { + assert!(!op.commands(&head).is_empty(), "{:?}", op.label()); + } + } + } + + #[test] + fn validation_rejects_names_that_git_would_read_as_something_else() { + let bad_branches = [ + "", + "feature branch", + "feat..ure", + "feat~1", + "feat^", + "refs:heads", + "-f", + "feature.lock", + "feat@{0}", + ]; + for name in bad_branches { + let err = GitOp::CheckoutBranch { name: name.into() } + .validate() + .expect_err("this name has to be rejected"); + assert_eq!(err.kind, GitOpErrorKind::InvalidArgument, "{name:?}"); + } + for name in ["feature", "feat/one", "release-1.2", "fix_9"] { + assert!( + GitOp::CheckoutBranch { name: name.into() } + .validate() + .is_ok(), + "{name:?} is a perfectly ordinary branch", + ); + } + + for rev in ["", "-f"] { + assert_eq!( + GitOp::Reset { + rev: rev.into(), + mode: ResetMode::Hard, + } + .validate() + .expect_err("bad rev") + .kind, + GitOpErrorKind::InvalidArgument, + ); + } + assert!( + GitOp::Reset { + rev: "HEAD~2".into(), + mode: ResetMode::Soft, + } + .validate() + .is_ok() + ); + + // Push's branch lands in a `HEAD:` refspec, where a `:` would + // start a second refspec — and `:foo` alone deletes remote `foo`. + for branch in [":main", "a:b", ""] { + assert_eq!( + GitOp::Push { + remote: "origin".into(), + branch: branch.into(), + set_upstream: false, + force_with_lease: false, + } + .validate() + .expect_err("a refspec-shaped branch has to be rejected") + .kind, + GitOpErrorKind::InvalidArgument, + "{branch:?}", + ); + } + } + + #[test] + fn destructive_marks_exactly_what_can_lose_work() { + let losing = [ + GitOp::DiscardWorktree { + paths: vec![p("a")], + }, + GitOp::DiscardUntracked { + paths: vec![p("a")], + directories: true, + }, + GitOp::Reset { + rev: "HEAD~1".into(), + mode: ResetMode::Hard, + }, + GitOp::Commit { + message: "m".into(), + amend: true, + signoff: false, + no_verify: false, + all: false, + }, + GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream: false, + force_with_lease: true, + }, + GitOp::DeleteBranch { + name: "dev".into(), + force: true, + }, + ]; + for op in losing { + assert!(op.destructive().is_some(), "{:?}", op.label()); + } + + let safe = [ + GitOp::Stage { + paths: vec![p("a")], + }, + GitOp::StageAll, + GitOp::Unstage { + paths: vec![p("a")], + }, + commit("m"), + GitOp::Reset { + rev: "HEAD~1".into(), + mode: ResetMode::Soft, + }, + GitOp::Fetch { + remote: None, + prune: true, + }, + GitOp::Push { + remote: "origin".into(), + branch: "main".into(), + set_upstream: true, + force_with_lease: false, + }, + GitOp::Stash { + message: None, + include_untracked: true, + }, + ]; + for op in safe { + assert_eq!(op.destructive(), None, "{:?}", op.label()); + } + } + + #[test] + fn network_operations_are_the_ones_that_need_the_long_deadline() { + for op in every_op() { + let expected = matches!(op.label(), "fetch" | "pull" | "push"); + assert_eq!(op.is_network(), expected, "{:?}", op.label()); + } + } + + fn kind_of(stderr: &str) -> GitOpErrorKind { + classify(stderr, Some(1)) + } + + #[test] + fn classify_recognizes_every_shape_of_credential_failure() { + for stderr in [ + "fatal: could not read Username for 'https://github.com': No such device or address", + "remote: Support for password authentication was removed.\n\ + fatal: Authentication failed for 'https://github.com/o/r.git/'", + "git@github.com: Permission denied (publickey).\n\ + fatal: Could not read from remote repository.", + "fatal: could not read Password for 'https://u@github.com': terminal prompts disabled", + "Host key verification failed.\nfatal: Could not read from remote repository.", + ] { + assert_eq!(kind_of(stderr), GitOpErrorKind::AuthRequired, "{stderr}"); + } + } + + #[test] + fn classify_recognizes_a_remote_that_cannot_be_reached() { + for stderr in [ + "fatal: unable to access 'https://github.com/o/r.git/': \ + Could not resolve host: github.com", + "ssh: connect to host github.com port 22: Connection timed out", + "ssh: connect to host 10.0.0.1 port 22: Network is unreachable", + "fatal: unable to access 'https://x/': Failed to connect to x port 443: \ + network is unreachable", + "ssh: connect to host localhost port 22: Connection refused", + ] { + assert_eq!( + kind_of(stderr), + GitOpErrorKind::NetworkUnreachable, + "{stderr}" + ); + } + } + + #[test] + fn classify_recognizes_a_rejected_push() { + for stderr in [ + " ! [rejected] main -> main (non-fast-forward)", + "error: failed to push some refs to 'github.com:o/r.git'\n\ + hint: Updates were rejected because the remote contains work that you do not have.", + " ! [rejected] main -> main (fetch first)", + "hint: the tip of your current branch is behind its remote counterpart", + ] { + assert_eq!(kind_of(stderr), GitOpErrorKind::NonFastForward, "{stderr}"); + } + } + + #[test] + fn classify_recognizes_a_commit_with_nothing_in_it() { + for text in [ + "On branch main\nnothing to commit, working tree clean", + "no changes added to commit (use \"git add\" and/or \"git commit -a\")", + ] { + assert_eq!(kind_of(text), GitOpErrorKind::NothingToCommit, "{text}"); + } + } + + #[test] + fn classify_recognizes_a_checkout_blocked_by_the_worktree() { + let stderr = "error: Your local changes to the following files would be overwritten \ + by checkout:\n\tsrc/main.rs\nPlease commit your changes or stash them."; + assert_eq!(kind_of(stderr), GitOpErrorKind::DirtyWorktree); + assert_eq!( + kind_of("hint: commit your local changes first"), + GitOpErrorKind::Other, + "both halves of the phrase are required", + ); + } + + #[test] + fn classify_recognizes_conflicts() { + for stderr in [ + "CONFLICT (content): Merge conflict in src/main.rs", + "error: Committing is not possible because you have unmerged files.\nUnmerged paths:", + "hint: after resolving the conflicts, mark the corrected paths", + ] { + assert_eq!(kind_of(stderr), GitOpErrorKind::Conflict, "{stderr}"); + } + } + + #[test] + fn classify_recognizes_a_held_index_lock() { + for stderr in [ + "fatal: Unable to create '/repo/.git/index.lock': File exists.", + "fatal: Unable to create '/repo/.git/shallow.lock': \ + Another git process seems to be running in this repository.", + ] { + assert_eq!(kind_of(stderr), GitOpErrorKind::LockHeld, "{stderr}"); + } + } + + #[test] + fn classify_recognizes_a_hook_saying_no() { + for stderr in [ + "remote: error: hook declined to update refs/heads/main", + "husky - pre-commit hook failed", + "husky - commit-msg hook exited with code 1 (error)", + ] { + assert_eq!(kind_of(stderr), GitOpErrorKind::HookRejected, "{stderr}"); + } + } + + #[test] + fn classify_recognizes_a_directory_that_is_not_a_repository() { + assert_eq!( + kind_of("fatal: not a git repository (or any of the parent directories): .git"), + GitOpErrorKind::NotARepo, + ); + } + + #[test] + fn classify_falls_back_to_other_and_to_spawn_when_git_never_answered() { + assert_eq!( + classify("fatal: bad revision 'nope'", Some(128)), + GitOpErrorKind::Other, + ); + assert_eq!(classify("", Some(1)), GitOpErrorKind::Other); + assert_eq!( + classify("", None), + GitOpErrorKind::Spawn, + "no exit code means it was killed, not that it failed", + ); + } + + // --- integration: a real repository in a temporary directory ------------ + + struct TempRepo { + dir: PathBuf, + } + + impl TempRepo { + fn new(tag: &str) -> TempRepo { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = std::env::temp_dir() + .join(format!("tty7-git-ops-{tag}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).expect("a temp directory"); + TempRepo { dir } + } + + fn write(&self, name: &str, body: &str) { + std::fs::write(self.dir.join(name), body).expect("write"); + } + } + + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// Runs git with the identity, signing and line-ending settings pinned, so + /// the test does not depend on the developer's `~/.gitconfig` or on Git for + /// Windows' system config. + fn run(host: &dyn Host, dir: &Path, args: &[&str]) -> String { + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + let out = host.git(dir, &full).expect("git runs"); + assert!( + out.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr), + ); + String::from_utf8_lossy(&out.stdout).into_owned() + } + + fn porcelain(host: &dyn Host, dir: &Path) -> Vec { + let mut lines: Vec = run(host, dir, &["status", "--porcelain"]) + .lines() + .map(str::to_string) + .collect(); + lines.sort(); + lines + } + + #[test] + fn a_stage_commit_unstage_discard_round_trip_moves_the_real_index() { + let repo = TempRepo::new("roundtrip"); + let host = crate::host::local::LocalHost::new(); + let host: &dyn Host = &*host; + let dir = repo.dir.clone(); + + run(host, &dir, &["init", "-q"]); + // In the repository, not just on the test's own command lines: the + // discard below is `run_op` running its own `checkout --`, and with + // Windows git's system-wide `core.autocrlf=true` that would hand back + // `one\r\n` for a file this test wrote as `one\n`. + assert!(pin_repo_config(&dir)); + + repo.write("a.txt", "one\n"); + // A name git would otherwise treat as a character class: proof that the + // `:(literal)` prefix is doing something. + repo.write("b[1].txt", "two\n"); + + let unborn = HeadState::Unborn { + branch: "main".into(), + }; + let outcome = run_op( + host, + &dir, + &GitOp::Stage { + paths: vec![p("a.txt"), p("b[1].txt")], + }, + &unborn, + ) + .expect("stage"); + assert_eq!(outcome.op, "stage"); + assert_eq!(porcelain(host, &dir), ["A a.txt", "A b[1].txt"]); + + // Before the first commit this has to go through `rm --cached`. + run_op( + host, + &dir, + &GitOp::Unstage { + paths: vec![p("b[1].txt")], + }, + &unborn, + ) + .expect("unstage on an unborn head"); + assert_eq!(porcelain(host, &dir), ["?? b[1].txt", "A a.txt"]); + + run_op(host, &dir, &GitOp::StageAll, &unborn).expect("stage all"); + run_op(host, &dir, &commit("initial"), &unborn).expect("commit"); + assert!(porcelain(host, &dir).is_empty(), "the commit took it all"); + + let oid = run(host, &dir, &["rev-parse", "HEAD"]).trim().to_string(); + let head = HeadState::Branch { + name: run(host, &dir, &["symbolic-ref", "--short", "HEAD"]) + .trim() + .to_string(), + oid, + }; + assert!(head.has_commits()); + + repo.write("a.txt", "one\ntwo\n"); + run_op( + host, + &dir, + &GitOp::Stage { + paths: vec![p("a.txt")], + }, + &head, + ) + .expect("stage the edit"); + assert_eq!(porcelain(host, &dir), ["M a.txt"]); + + run_op( + host, + &dir, + &GitOp::Unstage { + paths: vec![p("a.txt")], + }, + &head, + ) + .expect("unstage the edit"); + assert_eq!( + porcelain(host, &dir), + [" M a.txt"], + "the edit is back in the worktree only", + ); + + run_op( + host, + &dir, + &GitOp::DiscardWorktree { + paths: vec![p("a.txt")], + }, + &head, + ) + .expect("discard the edit"); + assert!(porcelain(host, &dir).is_empty(), "the edit is gone"); + // Byte for byte, which the repository's pinned line endings make a fact + // about the discard rather than about the machine running it. + assert_eq!( + std::fs::read_to_string(dir.join("a.txt")).expect("read back"), + "one\n", + ); + } + + #[test] + fn a_failure_reports_the_kind_and_an_argv_the_user_can_re_run() { + let repo = TempRepo::new("failure"); + let host = crate::host::local::LocalHost::new(); + let host: &dyn Host = &*host; + let dir = repo.dir.clone(); + + run(host, &dir, &["init", "-q"]); + assert!(pin_repo_config(&dir)); + repo.write("a.txt", "one\n"); + run(host, &dir, &["add", "-A"]); + run(host, &dir, &["commit", "-q", "-m", "initial"]); + + let head = HeadState::Branch { + name: "main".into(), + oid: run(host, &dir, &["rev-parse", "HEAD"]).trim().to_string(), + }; + + // Nothing is staged and nothing changed, so git refuses. + let err = run_op(host, &dir, &commit("empty"), &head).expect_err("nothing to commit"); + assert_eq!(err.kind, GitOpErrorKind::NothingToCommit); + assert_eq!(err.op, "commit"); + assert_eq!(err.cwd, dir); + assert_eq!(err.rerun_argv, ["git", "commit", "-m", "empty"]); + assert!(!err.message.is_empty()); + + let err = run_op( + host, + &dir, + &GitOp::CheckoutBranch { + name: "no-such-branch".into(), + }, + &head, + ) + .expect_err("no such branch"); + assert_eq!(err.kind, GitOpErrorKind::Other); + assert!( + err.rerun_argv.first().map(String::as_str) == Some("git"), + "the re-run argv is a whole command line: {:?}", + err.rerun_argv, + ); + } +} diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs new file mode 100644 index 00000000..00999163 --- /dev/null +++ b/crates/tty7-core/src/core/git/status.rs @@ -0,0 +1,1797 @@ +//! What the working tree looks like right now — the model behind the source +//! control panel, the file tree's decorations, and every button that is only +//! enabled for some file states. +//! +//! One `git status --porcelain=v2 --branch --show-stash -uall -z` answers all +//! of it. That format +//! is the only one that carries the staged and unstaged halves *separately* +//! (the `XY` pair), a rename's old path, unmerged stages, submodule sub-state, +//! and the branch header — getting the same picture out of `git diff` takes +//! four commands and a consistency window between them. +//! +//! The types live here rather than next to the parser because `ops` builds +//! commands out of them (`HeadState` decides how to unstage) and the GUI reads +//! them; the parser is just one producer. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::RecordSplitter; +use crate::host::{Entry, Host}; + +/// A path relative to the repository root, always `/`-separated. +/// +/// `-z` hands out raw bytes, and on Linux a path need not be UTF-8. When it is +/// not, `text` is the lossy rendering and `lossy` is set: such an entry can be +/// *shown* but never *acted on*, because `Host::git` takes `&[&str]` and the +/// control protocol carries args as `String` — the original bytes cannot reach +/// the far side. Callers must treat `pathspec() == None` as "read only". +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct RepoPath { + pub text: String, + pub lossy: bool, +} + +impl RepoPath { + pub fn from_bytes(bytes: &[u8]) -> RepoPath { + match std::str::from_utf8(bytes) { + Ok(text) => RepoPath { + text: text.to_string(), + lossy: false, + }, + Err(_) => RepoPath { + text: String::from_utf8_lossy(bytes).into_owned(), + lossy: true, + }, + } + } + + pub fn as_str(&self) -> &str { + &self.text + } + + /// The pathspec to hand git, or `None` if this path cannot be represented. + /// + /// `:(literal)` is not decoration: without it git globs the pathspec, so a + /// file actually named `a[b].txt` or `foo*` would not match itself. Callers + /// must additionally put a `--` ahead of the list so a file named `HEAD` or + /// `-f` is not read as a rev or an option. + pub fn pathspec(&self) -> Option { + (!self.lossy).then(|| format!(":(literal){}", self.text)) + } + + pub fn file_name(&self) -> &str { + match self.text.rsplit_once('/') { + Some((_, name)) => name, + None => &self.text, + } + } + + pub fn parent(&self) -> &str { + match self.text.rsplit_once('/') { + Some((dir, _)) => dir, + None => "", + } + } +} + +/// One half of porcelain v2's `XY` pair. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ChangeCode { + None, + Modified, + TypeChanged, + Added, + Deleted, + Renamed, + Copied, + Unmerged, +} + +impl ChangeCode { + pub fn from_byte(b: u8) -> Option { + Some(match b { + b'.' | b' ' => ChangeCode::None, + b'M' => ChangeCode::Modified, + b'T' => ChangeCode::TypeChanged, + b'A' => ChangeCode::Added, + b'D' => ChangeCode::Deleted, + b'R' => ChangeCode::Renamed, + b'C' => ChangeCode::Copied, + b'U' => ChangeCode::Unmerged, + _ => return None, + }) + } + + /// The single character the UI shows in its 14px status column. + pub fn letter(self) -> char { + match self { + ChangeCode::None => ' ', + ChangeCode::Modified => 'M', + ChangeCode::TypeChanged => 'T', + ChangeCode::Added => 'A', + ChangeCode::Deleted => 'D', + ChangeCode::Renamed => 'R', + ChangeCode::Copied => 'C', + ChangeCode::Unmerged => 'U', + } + } + + pub fn is_change(self) -> bool { + self != ChangeCode::None + } +} + +/// The seven `XY` pairs porcelain v2 reports as unmerged (`u`) records. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ConflictKind { + BothDeleted, + AddedByUs, + DeletedByThem, + AddedByThem, + DeletedByUs, + BothAdded, + BothModified, +} + +impl ConflictKind { + pub fn from_xy(x: u8, y: u8) -> Option { + Some(match (x, y) { + (b'D', b'D') => ConflictKind::BothDeleted, + (b'A', b'U') => ConflictKind::AddedByUs, + (b'U', b'D') => ConflictKind::DeletedByThem, + (b'U', b'A') => ConflictKind::AddedByThem, + (b'D', b'U') => ConflictKind::DeletedByUs, + (b'A', b'A') => ConflictKind::BothAdded, + (b'U', b'U') => ConflictKind::BothModified, + _ => return None, + }) + } + + /// Whether our side still has a file — decides if "open changes" can show + /// an ours/theirs diff or only one stage. + pub fn ours_exists(self) -> bool { + !matches!(self, ConflictKind::BothDeleted | ConflictKind::DeletedByUs) + } + + pub fn theirs_exists(self) -> bool { + !matches!( + self, + ConflictKind::BothDeleted | ConflictKind::DeletedByThem + ) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct SubmoduleState { + pub commit_changed: bool, + pub modified_content: bool, + pub has_untracked: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EntryKind { + Tracked, + Unmerged, + Untracked, + Ignored, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct StatusEntry { + pub path: RepoPath, + /// Only set on rename/copy records, and it is the *old* path. + pub orig_path: Option, + /// `X` — HEAD against the index, i.e. what is staged. + pub index: ChangeCode, + /// `Y` — the index against the working tree, i.e. what is not staged. + pub worktree: ChangeCode, + pub kind: EntryKind, + /// `None` when the entry is not a submodule. + pub submodule: Option, + /// Similarity score from `R` / `C`, 0..=100. + pub rename_score: Option, + /// Always `Some` when `kind == Unmerged`. + pub conflict: Option, +} + +impl StatusEntry { + pub fn is_staged(&self) -> bool { + self.index.is_change() && self.conflict.is_none() + } + + /// A file can be both staged and unstaged at once (`XY == "MM"`) and then + /// appears in both groups — which is exactly what VS Code shows. + pub fn is_unstaged(&self) -> bool { + self.worktree.is_change() && self.conflict.is_none() + } + + pub fn is_untracked(&self) -> bool { + matches!(self.kind, EntryKind::Untracked) + } + + pub fn is_conflicted(&self) -> bool { + self.conflict.is_some() + } + + /// How this entry should be decorated wherever a single status is shown + /// (file tree, commit detail): the worse of its two halves. + pub fn deco(&self) -> DecoStatus { + if self.is_conflicted() { + return DecoStatus::Conflict; + } + if self.is_untracked() { + return DecoStatus::Untracked; + } + // Explicit, not via the code match below: an ignored record carries no + // change codes, so it would otherwise fall through to `Modified` the + // day `--ignored` is passed — and light the whole ignored tree up. + if matches!(self.kind, EntryKind::Ignored) { + return DecoStatus::Ignored; + } + let worse = if code_rank(self.worktree) >= code_rank(self.index) { + self.worktree + } else { + self.index + }; + match worse { + ChangeCode::Deleted => DecoStatus::Deleted, + ChangeCode::Added => DecoStatus::Added, + ChangeCode::Renamed | ChangeCode::Copied => DecoStatus::Renamed, + ChangeCode::Unmerged => DecoStatus::Conflict, + _ => DecoStatus::Modified, + } + } +} + +fn code_rank(code: ChangeCode) -> u8 { + match code { + ChangeCode::None => 0, + ChangeCode::Modified | ChangeCode::TypeChanged => 1, + ChangeCode::Copied => 2, + ChangeCode::Renamed => 3, + ChangeCode::Added => 4, + ChangeCode::Deleted => 5, + ChangeCode::Unmerged => 6, + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum HeadState { + /// `# branch.oid (initial)` — the repository has no commits yet. + Unborn { + branch: String, + }, + Detached { + oid: String, + }, + Branch { + name: String, + oid: String, + }, +} + +impl HeadState { + /// What the chrome shows: a branch name, or a short sha when detached. + pub fn label(&self) -> String { + match self { + HeadState::Unborn { branch } => branch.clone(), + HeadState::Detached { oid } => oid.chars().take(7).collect(), + HeadState::Branch { name, .. } => name.clone(), + } + } + + /// `false` before the first commit, which is the one case where + /// `git reset HEAD -- ` fails outright. + pub fn has_commits(&self) -> bool { + !matches!(self, HeadState::Unborn { .. }) + } +} + +/// A sequencer operation left half-finished in the repository. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RepoOperation { + Merge, + Rebase, + RebaseInteractive, + CherryPick, + Revert, + Bisect, + Am, +} + +/// Entries past this are dropped; `total_entries` still reports the real count +/// so the panel can say so instead of quietly showing a short list. +pub const MAX_STATUS_ENTRIES: usize = 10_000; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct WorkingTreeStatus { + /// This worktree's toplevel. + pub root: PathBuf, + /// The shared repository home — differs from `root` inside a linked + /// worktree. Same rule `core::git::probe` already uses to group tabs. + pub home: PathBuf, + pub head: HeadState, + pub upstream: Option, + pub ahead_behind: Option<(u32, u32)>, + pub entries: Vec, + pub total_entries: usize, + pub truncated: bool, + pub stash_count: u32, + pub operation: Option, + /// `.git/MERGE_MSG` or `SQUASH_MSG`, to pre-fill the commit box mid-merge. + pub prefilled_message: Option, +} + +impl WorkingTreeStatus { + pub fn staged(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_staged()) + } + + pub fn unstaged(&self) -> impl Iterator { + self.entries + .iter() + .filter(|e| e.is_unstaged() && !e.is_untracked()) + } + + pub fn untracked(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_untracked()) + } + + pub fn conflicts(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_conflicted()) + } + + pub fn is_clean(&self) -> bool { + self.entries.is_empty() + } +} + +/// How a path is decorated wherever one status has to stand for a file. +/// +/// `Ord` is display precedence, lowest to highest: a directory takes the max of +/// everything beneath it, so one conflict anywhere colours the whole path up to +/// the root. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub enum DecoStatus { + Ignored, + Untracked, + Added, + Modified, + Renamed, + Deleted, + Conflict, +} + +/// Beyond this many entries the per-file map is dropped and only directories +/// stay decorated — a `node_modules` that slipped past `.gitignore` should slow +/// nothing down. +pub const MAX_DECORATED_FILES: usize = 5_000; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct DirRollup { + pub changed: bool, + pub conflict: bool, +} + +impl DirRollup { + pub fn merge(&mut self, status: DecoStatus) { + match status { + DecoStatus::Ignored => {} + DecoStatus::Conflict => { + self.conflict = true; + self.changed = true; + } + _ => self.changed = true, + } + } +} + +/// Repo-root-relative lookup for the file tree, built once per status refresh. +/// +/// Cost is O(changed paths × depth) — independent of how big the tree is — +/// and both lookups are a single hash probe, so a row can ask during render. +#[derive(Clone, Debug, Default)] +pub struct StatusIndex { + pub root: PathBuf, + files: HashMap, + dirs: HashMap, + /// Set when `files` was dropped for exceeding [`MAX_DECORATED_FILES`]. + pub files_dropped: bool, +} + +impl StatusIndex { + /// Fold a whole status into the lookup the file tree renders against. + /// + /// Built once per refresh on a background thread; the render path only ever + /// probes it. Doing it the other way round — asking the status for a path + /// while drawing a row — is a linear scan per visible row. + pub fn build(status: &WorkingTreeStatus) -> StatusIndex { + let mut index = StatusIndex { + root: status.root.clone(), + ..StatusIndex::default() + }; + for entry in &status.entries { + let deco = entry.deco(); + index.insert(entry.path.as_str(), deco); + // A rename's old path goes into the directory rollup only — the + // directory it left did lose a file. Not into `files`: the path + // can be occupied again (`git mv a b && echo x > a` emits an + // untracked record for `a`), and that row belongs to whatever + // occupies it now, not to the rename it outranks. + if let Some(orig) = &entry.orig_path { + index.rollup(orig.as_str(), deco); + } + } + if index.files.len() > MAX_DECORATED_FILES { + index.drop_files(); + } + index + } + + pub fn file(&self, repo_rel: &str) -> Option { + self.files.get(repo_rel).copied() + } + + pub fn dir(&self, repo_rel: &str) -> Option { + self.dirs.get(repo_rel).copied() + } + + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.dirs.is_empty() + } + + /// Insert one path and roll it up through every ancestor. Exposed so the + /// builder and its tests share exactly one definition of the walk. + pub fn insert(&mut self, repo_rel: &str, status: DecoStatus) { + self.files + .entry(repo_rel.to_string()) + .and_modify(|slot| *slot = (*slot).max(status)) + .or_insert(status); + self.rollup(repo_rel, status); + } + + /// Only the ancestor walk — for a path that must not claim a file row of + /// its own, like the old half of a rename. + fn rollup(&mut self, repo_rel: &str, status: DecoStatus) { + let mut cut = repo_rel; + while let Some((parent, _)) = cut.rsplit_once('/') { + self.dirs + .entry(parent.to_string()) + .or_default() + .merge(status); + cut = parent; + } + } + + pub fn drop_files(&mut self) { + self.files.clear(); + self.files_dropped = true; + } +} + +/// The one command behind everything above. +/// +/// `-z` is not a preference: without it any path with a space, a quote or a +/// newline comes back C-quoted. It also makes `core.quotePath=false` a no-op +/// here — measured, identical output either way — so that flag is carried only +/// to keep every git invocation in this module shaped the same; the place it +/// actually fixes something is the `diff --git` header. `--untracked-files=all` +/// pays for an extra walk, but a collapsed `dir/` row cannot be staged file by +/// file, which is most of what the panel is for. +const STATUS_ARGS: &[&str] = &[ + "-c", + "core.quotePath=false", + "status", + "--porcelain=v2", + "--branch", + "--show-stash", + "--untracked-files=all", + "-z", +]; + +/// `MERGE_MSG` is a commit message, not a file; anything past this is a +/// runaway and the commit box is better off empty than full of it. +const MAX_PREFILLED_MESSAGE: u64 = 64 * 1024; + +/// Everything one `--porcelain=v2` run can tell us on its own. +/// +/// Split out from [`WorkingTreeStatus`] because the remaining fields — where +/// the repository lives, and what sequencer operation is parked in it — come +/// from the filesystem, not from the parse. Keeping them apart is what lets the +/// header and record parsing be tested without a repository. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ParsedStatus { + pub head: HeadState, + pub upstream: Option, + /// `(ahead, behind)`. `None` means *unknown*, never *in sync*. + pub ahead_behind: Option<(u32, u32)>, + pub entries: Vec, + pub total_entries: usize, + pub truncated: bool, + pub stash_count: u32, +} + +impl ParsedStatus { + /// Finish the picture with what only a filesystem probe knows. + pub fn into_status( + self, + root: PathBuf, + home: PathBuf, + operation: Option, + prefilled_message: Option, + ) -> WorkingTreeStatus { + WorkingTreeStatus { + root, + home, + head: self.head, + upstream: self.upstream, + ahead_behind: self.ahead_behind, + entries: self.entries, + total_entries: self.total_entries, + truncated: self.truncated, + stash_count: self.stash_count, + operation, + prefilled_message, + } + } +} + +/// Parse the stdout of [`STATUS_ARGS`]. +/// +/// Never fails: git's output is only ever malformed if we asked for the wrong +/// format, and a status that is missing one unreadable row is far better for +/// the panel than no status at all. Records that do not parse are dropped. +pub fn parse_porcelain_v2(stdout: &[u8]) -> ParsedStatus { + let mut parser = Parser::default(); + let mut split = RecordSplitter::new(0); + split.push(stdout, |record| parser.record(record)); + let dropped = split.finish(|record| parser.record(record)); + let mut parsed = parser.finish(); + // A record past `MAX_RECORD` (a pathological pathname) is dropped whole; + // the status must say it is not the full picture, same as the entry cap. + parsed.truncated |= dropped > 0; + parsed +} + +#[derive(Default)] +struct Parser { + oid: Option, + head_name: Option, + upstream: Option, + ahead_behind: Option<(u32, u32)>, + stash_count: u32, + entries: Vec, + total: usize, + /// A `2` record's *old* path is a separate NUL token, so the record after + /// one is data rather than a new record. Parking the half-built entry here + /// is the whole reason a rename does not swallow the row behind it. + pending_rename: Option, +} + +impl Parser { + fn record(&mut self, record: &[u8]) { + if let Some(mut entry) = self.pending_rename.take() { + entry.orig_path = Some(RepoPath::from_bytes(record)); + self.push(entry); + return; + } + match record.first() { + Some(b'#') => self.header(record), + Some(b'1') => self.ordinary(record), + Some(b'2') => self.renamed(record), + Some(b'u') => self.unmerged(record), + Some(b'?') => self.bare(record, EntryKind::Untracked), + // We never pass `--ignored`, so this never arrives today. Handling + // it anyway costs one arm and means adding the flag later is a + // one-line change rather than a silent hole in the parse. + Some(b'!') => self.bare(record, EntryKind::Ignored), + _ => {} + } + } + + fn header(&mut self, record: &[u8]) { + let Ok(text) = std::str::from_utf8(record) else { + return; + }; + let Some(rest) = text.strip_prefix("# ") else { + return; + }; + let (key, value) = rest.split_once(' ').unwrap_or((rest, "")); + match key { + "branch.oid" => self.oid = Some(value.to_string()), + "branch.head" => self.head_name = Some(value.to_string()), + "branch.upstream" => self.upstream = Some(value.to_string()), + "branch.ab" => self.ahead_behind = parse_ab(value), + "stash" => self.stash_count = value.parse().unwrap_or(0), + _ => {} + } + } + + /// `1 ` + fn ordinary(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + let (Some(index), Some(worktree)) = (ChangeCode::from_byte(x), ChangeCode::from_byte(y)) + else { + return; + }; + if fields.skip(5).is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index, + worktree, + kind: EntryKind::Tracked, + submodule, + rename_score: None, + conflict: None, + }); + } + + /// `2 \0\0` + fn renamed(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + let (Some(index), Some(worktree)) = (ChangeCode::from_byte(x), ChangeCode::from_byte(y)) + else { + return; + }; + if fields.skip(5).is_none() { + return; + } + let Some(score) = fields.next() else { + return; + }; + self.pending_rename = Some(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index, + worktree, + kind: EntryKind::Tracked, + submodule, + rename_score: parse_score(score), + conflict: None, + }); + } + + /// `u

` + fn unmerged(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + if fields.skip(7).is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index: ChangeCode::from_byte(x).unwrap_or(ChangeCode::Unmerged), + worktree: ChangeCode::from_byte(y).unwrap_or(ChangeCode::Unmerged), + kind: EntryKind::Unmerged, + submodule, + rename_score: None, + // `XY` on a `u` record is the stage pair, not a change pair, so a + // conflict git does not name is still a conflict. + conflict: Some(ConflictKind::from_xy(x, y).unwrap_or(ConflictKind::BothModified)), + }); + } + + /// `? ` and `! ` — no fields, just the path. + fn bare(&mut self, record: &[u8], kind: EntryKind) { + let mut fields = Fields::new(record); + if fields.next().is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index: ChangeCode::None, + worktree: ChangeCode::None, + kind, + submodule: None, + rename_score: None, + conflict: None, + }); + } + + /// Past the cap we keep counting but stop keeping, so the panel can say + /// "10000 of 42311" rather than quietly showing a short list. + fn push(&mut self, entry: StatusEntry) { + self.total += 1; + if self.entries.len() < MAX_STATUS_ENTRIES { + self.entries.push(entry); + } + } + + fn finish(mut self) -> ParsedStatus { + // A `2` with nothing behind it means the output was cut short. The file + // is still real, so keep the row and lose only the old path. + if let Some(entry) = self.pending_rename.take() { + self.push(entry); + } + ParsedStatus { + head: head_state(self.oid, self.head_name), + upstream: self.upstream, + ahead_behind: self.ahead_behind, + truncated: self.total > self.entries.len(), + total_entries: self.total, + entries: self.entries, + stash_count: self.stash_count, + } + } +} + +/// Walks the fixed head of a record one space-delimited field at a time, then +/// hands back the rest verbatim — the path is always last and may contain +/// spaces, so it must never be split. +struct Fields<'a> { + rest: &'a [u8], +} + +impl<'a> Fields<'a> { + fn new(record: &'a [u8]) -> Fields<'a> { + Fields { rest: record } + } + + fn next(&mut self) -> Option<&'a [u8]> { + let at = self.rest.iter().position(|b| *b == b' ')?; + let (field, after) = self.rest.split_at(at); + self.rest = &after[1..]; + Some(field) + } + + fn skip(&mut self, n: usize) -> Option<()> { + for _ in 0..n { + self.next()?; + } + Some(()) + } + + /// The ` ` prefix that `1`, `2` and `u` records share. + fn prefix(&mut self) -> Option<(u8, u8, Option)> { + self.next()?; + let &[x, y] = self.next()? else { + return None; + }; + Some((x, y, parse_submodule(self.next()?))) + } + + fn tail(&self) -> &'a [u8] { + self.rest + } +} + +/// `N...` when the entry is not a submodule, otherwise `S`. +fn parse_submodule(field: &[u8]) -> Option { + let &[b'S', c, m, u] = field else { + return None; + }; + Some(SubmoduleState { + commit_changed: c == b'C', + modified_content: m == b'M', + has_untracked: u == b'U', + }) +} + +/// `R100` / `C75`. The letter repeats what `XY` already said, so only the +/// number is kept. +fn parse_score(field: &[u8]) -> Option { + let digits = std::str::from_utf8(field.get(1..)?).ok()?; + digits.parse::().ok().map(|n| n.min(100) as u8) +} + +/// `+2 -1`, from `# branch.ab`. +/// +/// `git status --no-ahead-behind` prints `+? -?` rather than dropping the line +/// (measured on git 2.50), so an unparsable pair has to mean *unknown*. Reading +/// it as zero would render as "in sync", which is the one wrong answer worse +/// than no answer at all. +fn parse_ab(value: &str) -> Option<(u32, u32)> { + let (ahead, behind) = value.split_once(' ')?; + Some(( + ahead.strip_prefix('+')?.parse().ok()?, + behind.strip_prefix('-')?.parse().ok()?, + )) +} + +fn head_state(oid: Option, head_name: Option) -> HeadState { + let branch = head_name.unwrap_or_default(); + match oid { + Some(oid) if oid == "(initial)" => HeadState::Unborn { branch }, + Some(oid) if branch == "(detached)" => HeadState::Detached { oid }, + Some(oid) => HeadState::Branch { name: branch, oid }, + None => HeadState::Unborn { branch }, + } +} + +/// What a status probe learned. The middle answer is the load-bearing one: +/// `Unreachable` is not an answer *about the repository* — the question could +/// not be asked — and treating it as "no repository here" made a dropped link +/// erase a panel that was showing perfectly good (if stale) data. +#[derive(Clone, PartialEq, Debug)] +pub enum StatusProbe { + Status(Box), + /// git ran and said so — an ordinary directory. + NotARepo, + /// git could not run, or ran and failed for a reason that is not "no + /// repository": a dead link, a timeout, an `index.lock` held by someone + /// else. Keep what is cached and ask again later. + Unreachable, +} + +/// The whole working tree state for the repository containing `cwd`. +/// +/// Three round trips in the common case — `rev-parse`, `status`, `read_dir` — +/// and each one is an RPC on a remote workspace, which is why none of them is +/// split into the several calls that would read more naturally. +pub fn probe_status(host: &dyn Host, cwd: &Path) -> StatusProbe { + let Ok(out) = host.git( + cwd, + &[ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ], + ) else { + return StatusProbe::Unreachable; + }; + if !out.success() { + // Exit 128 with this phrase is the ordinary answer for an ordinary + // directory; the phrase has been stable (modulo case) since git 1.x. + // Anything else is a repository that could not be read. + let stderr = String::from_utf8_lossy(&out.stderr).to_ascii_lowercase(); + return if stderr.contains("not a git repository") { + StatusProbe::NotARepo + } else { + StatusProbe::Unreachable + }; + } + let paths = String::from_utf8_lossy(&out.stdout).into_owned(); + let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); + let Some(root) = lines.next().map(PathBuf::from) else { + return StatusProbe::Unreachable; + }; + let git_dir = lines.next(); + let home = super::repo_home(&root, git_dir, lines.next()); + let Some(git_dir) = git_dir.map(PathBuf::from) else { + return StatusProbe::Unreachable; + }; + + let Ok(out) = host.git(cwd, STATUS_ARGS) else { + return StatusProbe::Unreachable; + }; + if !out.success() { + return StatusProbe::Unreachable; + } + let mut parsed = parse_porcelain_v2(&out.stdout); + if parsed.ahead_behind.is_none() + && let Some(upstream) = parsed.upstream.clone() + { + parsed.ahead_behind = rev_list_ahead_behind(host, cwd, &upstream); + } + + let listing = host.read_dir(&git_dir, None).unwrap_or_default(); + let operation = detect_operation(host, &git_dir, &listing); + let prefilled_message = + operation.and_then(|_| read_prefilled_message(host, &git_dir, &listing)); + StatusProbe::Status(Box::new(parsed.into_status( + root, + home, + operation, + prefilled_message, + ))) +} + +/// Ask for ahead/behind again when the header could not say. +/// +/// Only reached when `# branch.ab` is missing or unreadable, which measurement +/// says is rare: on git 2.50, `status.aheadBehind=false` does *not* suppress the +/// line for porcelain v2 (that config is documented as applying to non-porcelain +/// formats only), and `--no-ahead-behind` prints `+? -?` rather than dropping +/// it. This is a fallback for old git and for the configurations we did not +/// measure — not dead code, but not the normal path either. Do not promote it to +/// unconditional: on a remote workspace every call here is another RPC on a +/// refresh that already made three. +fn rev_list_ahead_behind(host: &dyn Host, cwd: &Path, upstream: &str) -> Option<(u32, u32)> { + let range = format!("{upstream}...HEAD"); + let out = super::git(host, cwd, &["rev-list", "--left-right", "--count", &range])?; + // Left of the `...` is the upstream, so the left count is what we are + // behind by — the opposite order from `# branch.ab`. + let (behind, ahead) = out.trim().split_once('\t')?; + Some((ahead.trim().parse().ok()?, behind.trim().parse().ok()?)) +} + +/// Which sequencer operation, if any, is parked in this repository. +/// +/// The order mirrors git's own in `wt_status_print_state`, and it matters more +/// than any individual test does: a conflicted `am` leaves `rebase-apply` +/// behind, a rebase stopped on a pick leaves `MERGE_MSG` behind, and a merge +/// leaves `AUTO_MERGE` behind. Only the precedence tells them apart. +fn detect_operation(host: &dyn Host, git_dir: &Path, listing: &[Entry]) -> Option { + let has = |name: &str| listing.iter().any(|e| e.name == name); + if has("MERGE_HEAD") { + return Some(RepoOperation::Merge); + } + if has("rebase-apply") { + // `applying` is the only thing separating `git am` from a rebase on the + // apply backend, and it sits one level down. The extra round trip is + // paid only while a rebase is actually parked. + return Some( + match dir_has(host, &git_dir.join("rebase-apply"), "applying") { + true => RepoOperation::Am, + false => RepoOperation::Rebase, + }, + ); + } + if has("rebase-merge") { + // Modern git writes `interactive` for *every* rebase on the merge + // backend, not only `-i` — measured on 2.50, where plain `git rebase` + // also makes `git status` say "interactive rebase in progress". Reading + // the same marker git reads keeps the two labels from disagreeing. + return Some( + match dir_has(host, &git_dir.join("rebase-merge"), "interactive") { + true => RepoOperation::RebaseInteractive, + false => RepoOperation::Rebase, + }, + ); + } + if has("CHERRY_PICK_HEAD") { + return Some(RepoOperation::CherryPick); + } + if has("REVERT_HEAD") { + return Some(RepoOperation::Revert); + } + if has("BISECT_LOG") { + return Some(RepoOperation::Bisect); + } + None +} + +fn dir_has(host: &dyn Host, dir: &Path, name: &str) -> bool { + host.read_dir(dir, None) + .is_ok_and(|listing| listing.iter().any(|e| e.name == name)) +} + +/// The message git already wrote for the operation in progress. +/// +/// Only read when something *is* in progress: outside a merge these files are +/// leftovers from the last commit, and pre-filling the box with a stale message +/// is how you accidentally commit it again. +fn read_prefilled_message(host: &dyn Host, git_dir: &Path, listing: &[Entry]) -> Option { + // `SQUASH_MSG` first: when both exist it is the more specific one. + for name in ["SQUASH_MSG", "MERGE_MSG"] { + if !listing.iter().any(|e| e.name == name) { + continue; + } + let Ok(bytes) = host.read_file(&git_dir.join(name), MAX_PREFILLED_MESSAGE) else { + continue; + }; + let text = String::from_utf8_lossy(&bytes).trim_end().to_string(); + if !text.is_empty() { + return Some(text); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::git::test_support::{PINS, one_spelling, pin_repo_config}; + + /// One NUL-terminated record. Samples are written this way because a string + /// literal with embedded NULs is unreadable, and because the separator is + /// exactly what half of these tests are about. + fn rec(parts: &[&str]) -> Vec { + let mut out = parts.concat().into_bytes(); + out.push(0); + out + } + + fn rec_bytes(prefix: &str, path: &[u8]) -> Vec { + let mut out = prefix.as_bytes().to_vec(); + out.extend_from_slice(path); + out.push(0); + out + } + + /// The five `# branch.*` lines with a real sha, so record tests do not have + /// to restate the header every time. + fn head_records() -> Vec { + [ + rec(&["# branch.oid 8d1b4a0e3c2f5b6a7d8e9f0a1b2c3d4e5f607182"]), + rec(&["# branch.head main"]), + ] + .concat() + } + + const SHA: &str = "8d1b4a0e3c2f5b6a7d8e9f0a1b2c3d4e5f607182"; + + /// A `1` record's fixed fields; only `XY` and the path ever vary here. + fn ordinary(xy: &str, path: &str) -> Vec { + rec(&[ + "1 ", + xy, + " N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + path, + ]) + } + + fn by_path<'a>(parsed: &'a ParsedStatus, path: &str) -> &'a StatusEntry { + parsed + .entries + .iter() + .find(|e| e.path.text == path) + .unwrap_or_else(|| panic!("no entry for {path}: {:?}", parsed.entries)) + } + + fn status_of(stdout: &[u8]) -> WorkingTreeStatus { + parse_porcelain_v2(stdout).into_status( + PathBuf::from("/repo"), + PathBuf::from("/repo"), + None, + None, + ) + } + + #[test] + fn a_full_branch_header_lands_in_every_field() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid ", SHA]), + rec(&["# branch.head feature/scm"]), + rec(&["# branch.upstream origin/feature/scm"]), + rec(&["# branch.ab +12 -3"]), + rec(&["# stash 4"]), + ] + .concat(), + ); + + assert_eq!( + parsed.head, + HeadState::Branch { + name: "feature/scm".into(), + oid: SHA.into(), + } + ); + assert_eq!(parsed.upstream.as_deref(), Some("origin/feature/scm")); + assert_eq!(parsed.ahead_behind, Some((12, 3))); + assert_eq!(parsed.stash_count, 4); + assert!(parsed.entries.is_empty()); + assert!(!parsed.truncated); + } + + #[test] + fn an_unborn_head_reports_its_branch_and_no_commits() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid (initial)"]), + rec(&["# branch.head main"]), + rec(&["? first.txt"]), + ] + .concat(), + ); + + assert_eq!( + parsed.head, + HeadState::Unborn { + branch: "main".into() + } + ); + assert!(!parsed.head.has_commits(), "reset HEAD would fatal here"); + assert_eq!(parsed.head.label(), "main"); + assert_eq!(parsed.entries.len(), 1, "the untracked file still parses"); + } + + #[test] + fn a_detached_head_keeps_the_sha_and_shows_it_short() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid ", SHA]), + rec(&["# branch.head (detached)"]), + ] + .concat(), + ); + + assert_eq!(parsed.head, HeadState::Detached { oid: SHA.into() }); + assert!(parsed.head.has_commits()); + assert_eq!(parsed.head.label(), "8d1b4a0"); + } + + #[test] + fn an_unknown_ahead_behind_pair_is_unknown_and_not_in_sync() { + // What `git status --no-ahead-behind` actually prints — measured on + // git 2.50, which does *not* drop the line. + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.upstream origin/main"]), + rec(&["# branch.ab +? -?"]), + ] + .concat(), + ); + assert_eq!(parsed.ahead_behind, None); + } + + #[test] + fn the_xy_pair_splits_staged_from_unstaged() { + let parsed = parse_porcelain_v2( + &[ + head_records(), + ordinary(".M", "unstaged.rs"), + ordinary("M.", "staged.rs"), + ordinary("MM", "both.rs"), + ordinary("A.", "added.rs"), + ordinary(".D", "gone-from-disk.rs"), + ordinary("D.", "staged-delete.rs"), + ] + .concat(), + ); + let staged: Vec<&str> = parsed + .entries + .iter() + .filter(|e| e.is_staged()) + .map(|e| e.path.as_str()) + .collect(); + let unstaged: Vec<&str> = parsed + .entries + .iter() + .filter(|e| e.is_unstaged()) + .map(|e| e.path.as_str()) + .collect(); + + assert_eq!( + staged, + ["staged.rs", "both.rs", "added.rs", "staged-delete.rs"] + ); + assert_eq!( + unstaged, + ["unstaged.rs", "both.rs", "gone-from-disk.rs"], + "`both.rs` belongs to both groups at once" + ); + + assert_eq!(by_path(&parsed, "both.rs").index, ChangeCode::Modified); + assert_eq!(by_path(&parsed, "both.rs").worktree, ChangeCode::Modified); + assert_eq!(by_path(&parsed, "added.rs").worktree, ChangeCode::None); + assert_eq!( + by_path(&parsed, "gone-from-disk.rs").worktree, + ChangeCode::Deleted + ); + assert_eq!( + by_path(&parsed, "gone-from-disk.rs").deco(), + DecoStatus::Deleted + ); + assert!(parsed.entries.iter().all(|e| e.submodule.is_none())); + } + + #[test] + fn a_rename_eats_its_old_path_and_nothing_else() { + // The regression this whole parser exists to avoid: `2` spends two NUL + // records, so a naive one-record-per-entry loop reads the row *behind* + // the rename as the old path and loses it. + let parsed = parse_porcelain_v2( + &[ + head_records(), + rec(&[ + "2 R. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " R100 src/new.rs", + ]), + rec(&["src/old.rs"]), + ordinary(".M", "after-the-rename.rs"), + rec(&["? untracked-behind-it.txt"]), + ] + .concat(), + ); + + assert_eq!( + parsed + .entries + .iter() + .map(|e| e.path.as_str()) + .collect::>(), + [ + "src/new.rs", + "after-the-rename.rs", + "untracked-behind-it.txt" + ], + "the record after the rename is a record, not the old path" + ); + + let renamed = by_path(&parsed, "src/new.rs"); + assert_eq!( + renamed.orig_path.as_ref().map(|p| p.as_str()), + Some("src/old.rs") + ); + assert_eq!(renamed.index, ChangeCode::Renamed); + assert_eq!(renamed.rename_score, Some(100)); + assert_eq!(renamed.deco(), DecoStatus::Renamed); + assert!(by_path(&parsed, "after-the-rename.rs").orig_path.is_none()); + } + + #[test] + fn a_copy_record_carries_its_similarity_score() { + let parsed = parse_porcelain_v2(&rec(&[ + "2 C. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " C75 copy.rs", + ])); + // Nothing followed it, so the old path is lost — but the file is not. + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].rename_score, Some(75)); + assert_eq!(parsed.entries[0].index, ChangeCode::Copied); + } + + #[test] + fn unmerged_records_become_conflicts_and_stay_out_of_both_groups() { + let unmerged = |xy: &str, path: &str| { + rec(&[ + "u ", + xy, + " N... 100644 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + SHA, + " ", + path, + ]) + }; + let parsed = parse_porcelain_v2( + &[ + head_records(), + unmerged("UU", "both-modified.rs"), + unmerged("AA", "both-added.rs"), + unmerged("DU", "deleted-by-us.rs"), + ] + .concat(), + ); + + assert_eq!( + by_path(&parsed, "both-modified.rs").conflict, + Some(ConflictKind::BothModified) + ); + assert_eq!( + by_path(&parsed, "both-added.rs").conflict, + Some(ConflictKind::BothAdded) + ); + assert_eq!( + by_path(&parsed, "deleted-by-us.rs").conflict, + Some(ConflictKind::DeletedByUs) + ); + assert!( + !by_path(&parsed, "deleted-by-us.rs") + .conflict + .unwrap() + .ours_exists() + ); + + for entry in &parsed.entries { + assert_eq!(entry.kind, EntryKind::Unmerged); + assert!(!entry.is_staged(), "{} leaked into Staged", entry.path.text); + assert!( + !entry.is_unstaged(), + "{} leaked into Changes", + entry.path.text + ); + assert_eq!(entry.deco(), DecoStatus::Conflict); + } + } + + #[test] + fn untracked_and_ignored_records_parse_without_fields() { + let parsed = parse_porcelain_v2( + &[ + head_records(), + rec(&["? build/out.o"]), + // We never pass `--ignored`, but the parser must not choke the + // day someone does. + rec(&["! target/debug/tty7"]), + ] + .concat(), + ); + + let untracked = by_path(&parsed, "build/out.o"); + assert_eq!(untracked.kind, EntryKind::Untracked); + assert!(untracked.is_untracked()); + assert!(!untracked.is_staged() && !untracked.is_unstaged()); + assert_eq!(untracked.deco(), DecoStatus::Untracked); + + assert_eq!( + by_path(&parsed, "target/debug/tty7").kind, + EntryKind::Ignored + ); + } + + #[test] + fn a_submodule_reports_its_three_sub_states() { + let parsed = parse_porcelain_v2(&rec(&[ + "1 .M S.MU 160000 160000 160000 ", + SHA, + " ", + SHA, + " vendor/lib", + ])); + + assert_eq!( + parsed.entries[0].submodule, + Some(SubmoduleState { + commit_changed: false, + modified_content: true, + has_untracked: true, + }) + ); + } + + #[test] + fn paths_with_spaces_quotes_and_newlines_survive_intact() { + // Every one of these is C-quoted without `-z`, which is the entire + // reason the parser works on bytes instead of lines. + let parsed = parse_porcelain_v2( + &[ + head_records(), + ordinary(".M", "d i r/sp ace.rs"), + ordinary("M.", "quote\"file.rs"), + rec(&["? new\nline.txt"]), + ordinary(".M", "back\\slash.rs"), + ] + .concat(), + ); + + assert_eq!( + by_path(&parsed, "d i r/sp ace.rs").path.file_name(), + "sp ace.rs" + ); + assert_eq!( + by_path(&parsed, "quote\"file.rs").index, + ChangeCode::Modified + ); + assert_eq!(by_path(&parsed, "new\nline.txt").kind, EntryKind::Untracked); + assert_eq!(by_path(&parsed, "back\\slash.rs").path.parent(), ""); + assert_eq!(parsed.entries.len(), 4); + } + + #[test] + fn a_non_utf8_path_is_shown_but_never_acted_on() { + // `caf\xe9.rs` — latin-1, which a Linux filesystem is perfectly happy + // to hold and which cannot be carried by the control protocol. + let parsed = parse_porcelain_v2(&rec_bytes( + &format!("1 .M N... 100644 100644 100644 {SHA} {SHA} "), + b"caf\xe9.rs", + )); + + let entry = &parsed.entries[0]; + assert!(entry.path.lossy); + assert!(entry.path.text.starts_with("caf")); + assert_eq!( + entry.path.pathspec(), + None, + "the UI has to grey out staging this row" + ); + } + + #[test] + fn past_the_entry_cap_the_count_is_still_the_real_one() { + let mut stdout = head_records(); + let overflow = 25; + for i in 0..MAX_STATUS_ENTRIES + overflow { + stdout.extend(ordinary(".M", &format!("f{i}.rs"))); + } + let parsed = parse_porcelain_v2(&stdout); + + assert_eq!(parsed.entries.len(), MAX_STATUS_ENTRIES); + assert_eq!(parsed.total_entries, MAX_STATUS_ENTRIES + overflow); + assert!(parsed.truncated); + assert_eq!( + parsed.entries[0].path.as_str(), + "f0.rs", + "the kept entries are the first ones, not a random slice" + ); + } + + #[test] + fn the_index_decorates_every_ancestor_up_to_the_root() { + let status = status_of( + &[ + head_records(), + ordinary(".M", "crates/core/src/git/status.rs"), + rec(&["? docs/notes.md"]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + assert_eq!(index.root, PathBuf::from("/repo")); + assert_eq!( + index.file("crates/core/src/git/status.rs"), + Some(DecoStatus::Modified) + ); + for dir in [ + "crates", + "crates/core", + "crates/core/src", + "crates/core/src/git", + ] { + assert_eq!( + index.dir(dir), + Some(DirRollup { + changed: true, + conflict: false + }), + "{dir} lost its rollup" + ); + } + assert_eq!(index.file("docs/notes.md"), Some(DecoStatus::Untracked)); + assert_eq!(index.dir("nowhere"), None); + assert!(!index.is_empty()); + } + + #[test] + fn one_conflict_colours_the_whole_path_to_the_root() { + let status = status_of( + &[ + head_records(), + ordinary(".M", "a/b/quiet.rs"), + rec(&[ + "u UU N... 100644 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + SHA, + " a/b/c/clash.rs", + ]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + for dir in ["a", "a/b", "a/b/c"] { + assert!(index.dir(dir).unwrap().conflict, "{dir} should be red"); + } + assert_eq!(index.file("a/b/quiet.rs"), Some(DecoStatus::Modified)); + } + + #[test] + fn a_renames_old_directory_is_rolled_up_too() { + let status = status_of( + &[ + head_records(), + rec(&[ + "2 R. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " R090 new/home.rs", + ]), + rec(&["old/home.rs"]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + assert!(index.dir("new").unwrap().changed); + assert!( + index.dir("old").unwrap().changed, + "the directory it left lost a file" + ); + assert_eq!( + index.file("old/home.rs"), + None, + "the old path holds no file row of its own" + ); + } + + /// `git mv a b && echo x > a`: the rename record's old path and a fresh + /// untracked file share a spelling. The row on disk is the untracked file; + /// the rename must not outrank it just because `Renamed > Untracked`. + #[test] + fn a_file_recreated_at_a_renames_old_path_decorates_as_itself() { + let status = status_of( + &[ + head_records(), + rec(&[ + "2 R. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " R090 b.rs", + ]), + rec(&["a.rs"]), + rec(&["? a.rs"]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + assert_eq!(index.file("a.rs"), Some(DecoStatus::Untracked)); + assert_eq!(index.file("b.rs"), Some(DecoStatus::Renamed)); + } + + /// `--ignored` is not passed today; the parser is future-proofed for it, + /// and the decoration must be too — an ignored record carries no change + /// codes and used to fall through to `Modified`. + #[test] + fn an_ignored_record_decorates_as_ignored_not_modified() { + let parsed = parse_porcelain_v2(&[head_records(), rec(&["! target"])].concat()); + let entry = &parsed.entries[0]; + assert_eq!(entry.kind, EntryKind::Ignored); + assert_eq!(entry.deco(), DecoStatus::Ignored); + } + + #[test] + fn past_the_decoration_cap_only_directories_stay_lit() { + let mut stdout = head_records(); + for i in 0..MAX_DECORATED_FILES + 1 { + stdout.extend(ordinary(".M", &format!("deep/dir/f{i}.rs"))); + } + let index = StatusIndex::build(&status_of(&stdout)); + + assert!(index.files_dropped); + assert_eq!(index.file("deep/dir/f0.rs"), None); + assert!( + index.dir("deep").unwrap().changed, + "folders still say where" + ); + assert!(index.dir("deep/dir").unwrap().changed); + } + + // ----- against a real repository ------------------------------------- + + struct Scratch(PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A probe that must have found a repository, unwrapped with a reason. + fn probed(probe: StatusProbe, why: &str) -> WorkingTreeStatus { + match probe { + StatusProbe::Status(status) => *status, + other => panic!("{why}: {other:?}"), + } + } + + fn scratch(name: &str) -> Option { + // The pid keeps two concurrent `cargo test` runs off each other's + // fixture, since the directory is wiped on the way in — the same + // reason `log.rs` carries one. + let dir = + std::env::temp_dir().join(format!("tty7-scm-status-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok()?; + Some(Scratch(dir)) + } + + /// Runs git with the identity, signing and line-ending settings pinned, so + /// the test does not depend on whatever is in the developer's + /// `~/.gitconfig` or in Git for Windows' system config. + fn run(host: &dyn Host, cwd: &Path, args: &[&str]) -> bool { + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + host.git(cwd, &full).map(|o| o.success()).unwrap_or(false) + } + + /// `git init` on a branch named here, with the pins written into the + /// repository so that `probe_status`'s own git reads them too. `false` + /// means there is no git on this machine, which is not a failure. + fn init_repo(host: &dyn Host, repo: &Path) -> bool { + if !run(host, repo, &["init", "--quiet"]) { + return false; + } + assert!(pin_repo_config(repo)); + // Not `init -b`: that is git 2.28+, and the branch name is asserted on. + assert!(run( + host, + repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + true + } + + #[test] + fn a_real_repository_reports_all_four_shapes_at_once() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("real-repo") else { + return; + }; + let repo = &scratch.0; + + if !init_repo(&*host, repo) { + return; // no git on this machine + } + std::fs::write(repo.join("kept.txt"), "one\n").unwrap(); + std::fs::write(repo.join("moved.txt"), "a\nb\nc\nd\ne\nf\ng\nh\n").unwrap(); + assert!(run(&*host, repo, &["add", "-A"])); + assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); + + std::fs::write(repo.join("staged.txt"), "new\n").unwrap(); + assert!(run(&*host, repo, &["add", "staged.txt"])); + std::fs::write(repo.join("kept.txt"), "one\ntwo\n").unwrap(); + assert!(run(&*host, repo, &["mv", "moved.txt", "renamed.txt"])); + std::fs::write(repo.join("untracked.txt"), "loose\n").unwrap(); + + let status = probed( + probe_status(&*host, repo), + "a repository was just created here", + ); + + match &status.head { + HeadState::Branch { name, oid } => { + assert_eq!(name, "main"); + assert_eq!(oid.len(), 40, "the header carries the full sha: {oid}"); + } + other => panic!("expected a branch, got {other:?}"), + } + assert_eq!(status.upstream, None); + assert_eq!(status.ahead_behind, None, "no upstream, no number"); + assert_eq!(status.operation, None); + assert_eq!(status.prefilled_message, None); + assert_eq!(status.stash_count, 0); + assert!(!status.truncated); + assert!(!status.is_clean()); + // `status.root` is what `git rev-parse` answered, so the two sides have + // to be brought into one spelling before they can be compared — see + // `one_spelling`. Still an exact equality, just not a literal one. + assert_eq!( + one_spelling(&status.root), + one_spelling(&std::fs::canonicalize(repo).unwrap()) + ); + assert_eq!(status.home, status.root); + + fn names(mut v: Vec<&str>) -> Vec<&str> { + v.sort_unstable(); + v + } + assert_eq!( + names(status.staged().map(|e| e.path.as_str()).collect()), + ["renamed.txt", "staged.txt"] + ); + assert_eq!( + names(status.unstaged().map(|e| e.path.as_str()).collect()), + ["kept.txt"] + ); + assert_eq!( + names(status.untracked().map(|e| e.path.as_str()).collect()), + ["untracked.txt"] + ); + assert_eq!(status.conflicts().count(), 0); + + let renamed = status + .entries + .iter() + .find(|e| e.path.as_str() == "renamed.txt") + .unwrap(); + assert_eq!(renamed.index, ChangeCode::Renamed); + assert_eq!( + renamed.orig_path.as_ref().map(|p| p.as_str()), + Some("moved.txt") + ); + + let index = StatusIndex::build(&status); + assert_eq!(index.file("kept.txt"), Some(DecoStatus::Modified)); + assert_eq!(index.file("untracked.txt"), Some(DecoStatus::Untracked)); + } + + #[test] + fn a_merge_in_progress_is_named_and_pre_fills_its_message() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("merge-op") else { + return; + }; + let repo = &scratch.0; + + if !init_repo(&*host, repo) { + return; + } + std::fs::write(repo.join("c.txt"), "base\n").unwrap(); + assert!(run(&*host, repo, &["add", "-A"])); + assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); + assert!(run(&*host, repo, &["checkout", "--quiet", "-b", "other"])); + std::fs::write(repo.join("c.txt"), "theirs\n").unwrap(); + assert!(run(&*host, repo, &["commit", "--quiet", "-am", "theirs"])); + assert!(run(&*host, repo, &["checkout", "--quiet", "main"])); + std::fs::write(repo.join("c.txt"), "ours\n").unwrap(); + assert!(run(&*host, repo, &["commit", "--quiet", "-am", "ours"])); + // Expected to fail — that is the point. + run(&*host, repo, &["merge", "other"]); + + let status = probed(probe_status(&*host, repo), "still a repository mid-merge"); + assert_eq!(status.operation, Some(RepoOperation::Merge)); + assert!( + status + .prefilled_message + .as_deref() + .is_some_and(|m| m.contains("other")), + "MERGE_MSG should seed the commit box: {:?}", + status.prefilled_message + ); + assert_eq!(status.conflicts().count(), 1); + assert_eq!( + status.conflicts().next().unwrap().conflict, + Some(ConflictKind::BothModified) + ); + } + + #[test] + fn an_upstream_gives_ahead_and_behind_without_a_second_command() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("upstream") else { + return; + }; + let remote = scratch.0.join("remote.git"); + let repo = scratch.0.join("clone"); + std::fs::create_dir_all(&repo).unwrap(); + + if !run( + &*host, + &scratch.0, + &["init", "--quiet", "--bare", "remote.git"], + ) { + return; + } + assert!(init_repo(&*host, &repo)); + std::fs::write(repo.join("f.txt"), "one\n").unwrap(); + assert!(run(&*host, &repo, &["add", "-A"])); + assert!(run(&*host, &repo, &["commit", "--quiet", "-m", "one"])); + assert!(run( + &*host, + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()] + )); + assert!(run( + &*host, + &repo, + &["push", "--quiet", "-u", "origin", "main"] + )); + std::fs::write(repo.join("f.txt"), "two\n").unwrap(); + assert!(run(&*host, &repo, &["commit", "--quiet", "-am", "two"])); + + let status = probed(probe_status(&*host, &repo), "a repository with a remote"); + assert_eq!(status.upstream.as_deref(), Some("origin/main")); + // Straight from `# branch.ab`; the `rev-list` fallback never runs here. + assert_eq!( + status.ahead_behind, + Some((1, 0)), + "ahead first, behind second" + ); + assert!(status.is_clean()); + } + + /// The two negative answers stay distinct: an ordinary directory is + /// `NotARepo` (record it, stop asking), a directory git could not even be + /// run in is `Unreachable` (keep what is cached, ask again later). + #[test] + fn outside_a_repository_there_is_no_status() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("not-a-repo") else { + return; + }; + assert_eq!(probe_status(&*host, &scratch.0), StatusProbe::NotARepo); + assert_eq!( + probe_status(&*host, Path::new("/no/such/tty7/path")), + StatusProbe::Unreachable, + "a cwd that cannot be entered is not an answer about a repository" + ); + } +} diff --git a/crates/tty7-core/src/host/conformance.rs b/crates/tty7-core/src/host/conformance.rs index b08661c6..0328ae66 100644 --- a/crates/tty7-core/src/host/conformance.rs +++ b/crates/tty7-core/src/host/conformance.rs @@ -54,7 +54,10 @@ macro_rules! for_each_host_case { git_nonzero_exit_is_ok_not_err, git_that_cannot_run_is_err, git_optional_locks_env_is_set, + git_terminal_prompt_is_disabled, git_stdin_is_null, + git_output_preserves_nul_bytes, + git_args_survive_pathspec_magic, join_uses_host_separator, is_absolute_matches_host_semantics, search_is_breadth_first, @@ -660,6 +663,53 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) { ); } +pub fn git_terminal_prompt_is_disabled(h: &dyn Host, sb: &dyn Sandbox) { + let sandbox = sb.path(); + let repo = h.join(sandbox, "repo"); + mkdir(h, &repo); + let Some(()) = git_repo(h, &repo) else { return }; + + // Failures past this point assert rather than return: a broken alias or a + // failing run is exactly a git that misbehaved, and returning would make + // this case pass vacuously in precisely that situation. + let out = h + .git( + &repo, + &[ + "config", + "alias.tty7prompt", + "!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]", + ], + ) + .unwrap(); + assert!( + out.success(), + "config exited {:?}: {:?}", + out.status, + out.stderr_trimmed() + ); + let out = h.git(&repo, &["tty7prompt"]).unwrap(); + assert!( + out.success(), + "alias run exited {:?}: {:?}", + out.status, + out.stderr_trimmed() + ); + // A `push` that stops to ask for a username never comes back — and on the + // far side of a control link there is no terminal to answer at anyway. The + // remote host inherits this from the server's own local host, so both ends + // have to agree. + let text = out.stdout_trimmed(); + assert!( + text.contains("PROMPT=[0]"), + "GIT_TERMINAL_PROMPT must reach git: {text:?}" + ); + assert!( + text.contains("REQUIRE=[never]"), + "SSH_ASKPASS_REQUIRE must reach git: {text:?}" + ); +} + pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let repo = h.join(sandbox, "repo"); @@ -678,6 +728,74 @@ pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) { }); } +pub fn git_output_preserves_nul_bytes(h: &dyn Host, sb: &dyn Sandbox) { + let sandbox = sb.path(); + let repo = h.join(sandbox, "repo"); + mkdir(h, &repo); + let Some(()) = git_repo(h, &repo) else { return }; + + write(h, &h.join(&repo, "one two.txt"), "x"); + write(h, &h.join(&repo, "three.txt"), "y"); + let out = h.git(&repo, &["status", "--porcelain=v2", "-z"]).unwrap(); + assert!( + out.success(), + "status exited {:?}: {:?}", + out.status, + out.stderr_trimmed() + ); + + // `git` hands back bytes, not lines. The `-z` formats are the only ones + // whose paths are unambiguous, and the SCM panel reads them through this + // method precisely because `git_lines` cannot: it splits on newlines and + // rejoins with them, which turns a NUL stream into mush. + assert!( + out.stdout.contains(&0), + "`-z` came back with no NUL at all: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + !out.stdout.contains(&b'\n'), + "`-z` records were re-terminated with newlines: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + contains_bytes(&out.stdout, b"one two.txt"), + "the raw, unquoted path did not survive: {:?}", + String::from_utf8_lossy(&out.stdout) + ); +} + +pub fn git_args_survive_pathspec_magic(h: &dyn Host, sb: &dyn Sandbox) { + let sandbox = sb.path(); + let repo = h.join(sandbox, "repo"); + mkdir(h, &repo); + let Some(()) = git_repo(h, &repo) else { return }; + + // Staging a single file means naming it as a pathspec, and a name with + // glob characters only stages itself behind `:(literal)`. Nothing between + // here and git may re-split, re-quote or shell-expand that argument. + let name = "a[b].txt"; + write(h, &h.join(&repo, name), "x"); + let added = h.git(&repo, &["add", "--", ":(literal)a[b].txt"]).unwrap(); + assert!( + added.success(), + "add exited {:?}: {:?}", + added.status, + added.stderr_trimmed() + ); + + let out = h.git(&repo, &["status", "--porcelain"]).unwrap(); + let text = out.stdout_trimmed(); + assert!( + text.lines().any(|l| l.starts_with('A') && l.contains(name)), + "`:(literal)` did not reach git intact: {text:?}" + ); +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sep = h.separator(); diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs index 403ffc69..be391fa7 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -16,6 +16,49 @@ use crate::host::{ const COALESCE_WINDOW: Duration = Duration::from_millis(100); +/// What every git we spawn runs with, on top of what `git_output_with_env` +/// already sets: nothing may stop and ask a human anything. +/// +/// It only bites when git reaches for a credential, which in practice is +/// `fetch`/`pull`/`push` — `status`, `diff` and `log` have nothing to prompt +/// about, so carrying it on the read path too costs nothing and buys the one +/// thing we cannot get any other way: the wire carries a single `Git` request +/// with no "this one talks to a network" bit, so the remote `tty7-server` +/// arrives here with the same args and is protected by the same rule, without a +/// protocol change. +/// +/// `git_output_with_env` already nulls stdin, and that is not enough — with no +/// `GIT_TERMINAL_PROMPT` git opens `/dev/tty` directly and blocks on it, which +/// is exactly the hang this prevents. +const NO_PROMPT_ENV: &[(&str, Option<&str>)] = &[ + // Fail instead of blocking on `Username for 'https://…'`. + ("GIT_TERMINAL_PROMPT", Some("0")), + // Nobody is watching for a password dialog a background probe popped up. + ("GIT_ASKPASS", None), + ("SSH_ASKPASS", None), + // OpenSSH 8.4+; without it ssh may fall back to askpass on its own. + ("SSH_ASKPASS_REQUIRE", Some("never")), +]; + +/// `NO_PROMPT_ENV`, plus a batch-mode ssh unless the user picked their own +/// `GIT_SSH_COMMAND` — replacing theirs would drop the identity file or jump +/// host they configured. `BatchMode=yes` bans only interactive password and +/// passphrase prompts; a key held by ssh-agent still authenticates. +/// +/// A repository's `core.sshCommand` does lose to this, because that is git's +/// own precedence. Honouring it would mean a `git config` probe before every +/// call, and this is the read path too. +fn no_prompt_env() -> Vec<(&'static str, Option<&'static str>)> { + let mut env = NO_PROMPT_ENV.to_vec(); + // `GIT_SSH` too: it is the older spelling of the same choice (plink on + // Windows, most commonly), and `GIT_SSH_COMMAND` outranks it — forcing + // ours would silently swap their transport out. + if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() { + env.push(("GIT_SSH_COMMAND", Some("ssh -o BatchMode=yes"))); + } + env +} + pub struct LocalHost { gitignore: Arc>, } @@ -239,7 +282,7 @@ impl Host for LocalHost { fn git(&self, cwd: &Path, args: &[&str]) -> io::Result { guard_off_ui(); - git::git_output(cwd, args) + git::git_output_with_env(cwd, args, &no_prompt_env()) } fn git_lines( diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 5e59f244..58552739 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -216,6 +216,30 @@ pub trait Host: Send + Sync + 'static { fn git(&self, cwd: &Path, args: &[&str]) -> io::Result; + /// `git`, but with an explicit ceiling on how long to wait for the far side. + /// + /// Network verbs (`fetch`/`pull`/`push`) run for as long as the network + /// takes, which is minutes, not the seconds an interactive query is allowed. + /// + /// `LocalHost` deliberately does not override this: `Command::output()` + /// blocks until the child exits and has no timeout of its own, so waiting + /// "until the deadline" and waiting "until git is done" are the same wait — + /// forwarding to `git` is the honest implementation, not a stub. `RemoteHost` + /// does override it, because its per-request deadline is sized for + /// interactive queries and a push walks straight into it. + /// + /// The reply is still the plain `Git` request on the wire, so a server that + /// predates this method serves it unchanged. + fn git_with_deadline( + &self, + cwd: &Path, + args: &[&str], + deadline: std::time::Duration, + ) -> io::Result { + let _ = deadline; + self.git(cwd, args) + } + fn git_lines( &self, cwd: &Path, diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 3f2090e9..3f5f4fa1 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -277,6 +277,25 @@ impl Host for RemoteHost { } } + fn git_with_deadline( + &self, + cwd: &Path, + args: &[&str], + deadline: Duration, + ) -> io::Result { + // Byte-for-byte the same request `git` sends; only the client's own + // patience changes. The server never times a job out, so a v5 peer that + // knows nothing about long git verbs serves this unchanged. + let req = ControlRequest::Git { + cwd: wire_path(cwd), + args: args.iter().map(|a| a.to_string()).collect(), + }; + match self.client.call_with_deadline(req, &[], deadline)?.reply { + ReplyOk::Output(o) => Ok(o), + other => Err(wrong_shape("a process result", &other)), + } + } + fn git_lines( &self, cwd: &Path, diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 4cb0b1e2..9ad1846f 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -2210,6 +2210,46 @@ mod tests { } } + #[test] + fn a_short_deadline_gives_up_on_a_slow_git() { + let p = pair_with(Arc::new(SlowGit { + inner: LocalHost::new(), + delay: Duration::from_millis(300), + running: Arc::new(AtomicBool::new(false)), + })); + let tmp = tempfile::TempDir::new().unwrap(); + + let err = p + .host + .git_with_deadline(tmp.path(), &["status"], Duration::from_millis(100)) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::TimedOut, "{err}"); + } + + #[test] + fn a_long_deadline_outlasts_the_one_a_git_request_would_get() { + let p = pair_with(Arc::new(SlowGit { + inner: LocalHost::new(), + delay: Duration::from_millis(300), + running: Arc::new(AtomicBool::new(false)), + })); + let tmp = tempfile::TempDir::new().unwrap(); + + // The first call abandons its request mid-flight. The server has no + // timer of its own — it runs the job to completion and answers into a + // slot nobody is waiting on — so the point of doing it twice is that + // the link is still usable afterwards. A `push` behind a cancelled + // probe depends on exactly that. + let _ = p + .host + .git_with_deadline(tmp.path(), &["status"], Duration::from_millis(100)); + let out = p + .host + .git_with_deadline(tmp.path(), &["status"], Duration::from_secs(5)) + .unwrap(); + assert_eq!(out.stdout, b"slow"); + } + #[test] fn replies_come_back_out_of_order() { let running = Arc::new(AtomicBool::new(false)); diff --git a/src/core/actions.rs b/src/core/actions.rs index c539cf0e..d4689f7b 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -67,8 +67,25 @@ actions!( ToggleLeftPanel, ToggleRightPanel, ShowRightPanelInfo, + // Keeps its old name even though the panel is now "Source Control": + // `Config::keybindings` is keyed by action name, so renaming it would + // orphan every custom binding already on disk. ShowRightPanelChanges, ShowRightPanelFiles, + ScmCommit, + ScmCommitAmend, + ScmStageAll, + ScmUnstageAll, + ScmDiscardAll, + ScmRefresh, + ScmSync, + ScmPush, + ScmPull, + ScmFetch, + ScmCheckoutBranch, + ScmCreateBranch, + ScmToggleGraph, + ToggleDiffViewMode, OpenSettings, ShowKeyboardShortcuts, About, diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs new file mode 100644 index 00000000..3b7e8793 --- /dev/null +++ b/src/terminal/git_data.rs @@ -0,0 +1,1809 @@ +//! The source control panel's data pipeline: one cache of what each repository +//! looks like, one way to change it, and one way to say "that is now stale". +//! +//! Deliberately separate from [`super::git_status`]. That cache answers a +//! cheap question — branch name and a `+N −M` for a tab badge — on every cwd +//! change and every command boundary, for every pane. This one answers the +//! expensive question (`status --porcelain=v2 -uall`, seconds on a large +//! repository) and only while something is actually looking. Folding the two +//! would put the expensive probe on the cheap trigger. +//! +//! Invalidation is by epoch rather than by key. Working out which cache +//! entries a `git add` touched is a losing game; bumping a counter for the +//! repository and letting readers notice they are behind is not. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use gpui::{Context, Window}; + +use crate::core::git::ops::{GitOp, GitOpError, GitOpErrorKind, GitOpOutcome, run_op}; +use crate::core::git::status::{StatusIndex, StatusProbe, WorkingTreeStatus, probe_status}; +use crate::ui::app::Tty7App; +use crate::ui::host_ops::{ByHost, Host, HostId, HostOps, InFlight, SharedHost, WatchSub}; + +/// How many network operations one (host, repository) pair may have in flight. +/// +/// The far side serves every request from one worker pool, and keepalive's +/// `Ping` queues behind the rest of it. Enough concurrent pushes and the ping +/// misses its own deadline for long enough that the link is declared dead — +/// so the client, not the server, keeps the number small. Counted per +/// repository rather than per host, so a long fetch in one repository cannot +/// lock the sync tile of another; the price is that N busy repositories on +/// one host can hold N × this many operations, which the panel's own layout +/// (one repository on screen) keeps theoretical. +pub const MAX_CONCURRENT_NETWORK_OPS: usize = 2; + +/// How long a probe that could not reach its host rests before it is asked +/// again. Only the render-driven retry waits this out — any real invalidation +/// (a watcher event, a write, the Refresh button) bumps the epoch, which +/// clears the rest and retries at once. +pub const PROBE_FAILURE_RETRY: Duration = Duration::from_secs(10); + +/// How long a failed watch open rests before it is tried again. +/// +/// Without this the retry runs at frame rate: a failed open leaves the +/// repository in `unwatched()`, which `scm_sync_watchers` reads at the top of +/// every render — and a host that cannot watch (inotify limit, a server +/// without the capability) would pay a `rev-parse` + `read_dir` + `watch` +/// round trip per frame, forever. +pub const WATCH_RETRY: Duration = Duration::from_secs(10); + +/// How quiet a burst of invalidations has to go before we believe it is over. +pub const GIT_WATCH_DEBOUNCE: Duration = Duration::from_millis(250); + +/// …and how long we are willing to keep waiting for that quiet. A checkout +/// rewrites `.git` continuously for longer than the debounce window, and +/// showing nothing for the whole of it reads as a hang. +pub const GIT_WATCH_MAX_DELAY: Duration = Duration::from_millis(1000); + +/// How many namespace directories under `refs/heads` we will watch. +/// +/// `Host::watch` is non-recursive, so `refs/heads/feat/x` is only seen if +/// `refs/heads/feat` is listed by name. Past this many namespaces we list none +/// of them: `packed-refs` and `` itself catch nearly every branch +/// operation anyway, and the command boundary catches the rest. +pub const MAX_WATCHED_REF_DIRS: usize = 64; + +/// The directories a repository's `.git` needs watched, in the order they are +/// passed to [`Host::watch`]. +/// +/// A pure function of the three answers `rev-parse` gives, so the layout rules +/// can be tested without a repository: +/// +/// - `` carries `index`, `HEAD`, `ORIG_HEAD`, `MERGE_HEAD`, +/// `CHERRY_PICK_HEAD`. It is the one that matters most; nearly every verb +/// touches something in it. +/// - `` is where `packed-refs` lives, and in a linked worktree it +/// is *not* `` — that is the whole reason both are asked for. +/// - the three `refs/` directories, plus the namespaces below `refs/heads`, +/// because the watch does not recurse. +/// +/// Deliberately absent: the working tree. Recursively watching a working tree +/// over SSH is a disaster, and edits there already arrive on the same +/// invalidation bus from the file tree, the editor and the command boundary. +pub fn git_watch_dirs( + sep: char, + git_dir: &Path, + common_dir: &Path, + head_namespaces: &[String], +) -> Vec { + use tty7_core::host::default_join; + + let mut dirs = vec![git_dir.to_path_buf()]; + if common_dir != git_dir { + dirs.push(common_dir.to_path_buf()); + } + let refs = default_join(common_dir, "refs", sep); + let heads = default_join(&refs, "heads", sep); + dirs.push(heads.clone()); + dirs.push(default_join(&refs, "remotes", sep)); + dirs.push(default_join(&refs, "tags", sep)); + if head_namespaces.len() <= MAX_WATCHED_REF_DIRS { + dirs.extend( + head_namespaces + .iter() + .map(|name| default_join(&heads, name, sep)), + ); + } + dirs +} + +/// What a debounced repository wants next. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DebounceStep { + /// No burst is open; the timer that asked should stop. + Idle, + /// Sleep this long and ask again. + Wait(Duration), + /// Probe now. + Fire, +} + +/// Collapses a burst of invalidations into one probe. +/// +/// Two clocks, because either alone is wrong: the quiet period alone is +/// starved by an operation that writes `.git` continuously (a checkout, a +/// rebase), and the ceiling alone fires in the middle of a burst that was +/// about to end. +/// +/// There is no self-triggering to defend against here, and the reason is +/// structural rather than lucky: every read goes through `core::git`'s +/// `git_output`, which sets `GIT_OPTIONAL_LOCKS=0`. That variable's only +/// effect is to stop `git status` refreshing and writing back `.git/index`, so +/// the probes this schedules provably cannot wake the watcher that schedules +/// them. Writes *do* wake it, and that is wanted — `run_git_op` announces +/// itself on the same bus, so the write's own notification and the watcher +/// event a few milliseconds behind it land in one window and cost one probe. +#[derive(Default)] +pub struct Debounce { + /// When the open burst started, if one is open. + opened: Option, + /// The newest event in the open burst. + latest: Option, + /// Bumped when a burst opens, so the timer left over from an older burst + /// wakes, finds it is not the one, and stops. + seq: u64, +} + +impl Debounce { + /// Record an event. `Some(seq)` means this opened a burst and the caller + /// owes it a timer; `None` means one is already running. + pub fn note(&mut self, now: Instant) -> Option { + self.latest = Some(now); + if self.opened.is_some() { + return None; + } + self.opened = Some(now); + self.seq += 1; + Some(self.seq) + } + + pub fn seq(&self) -> u64 { + self.seq + } + + /// What the timer should do now. `Fire` closes the burst, so it is + /// returned exactly once however many events went into it. + pub fn poll(&mut self, now: Instant) -> DebounceStep { + let (Some(opened), Some(latest)) = (self.opened, self.latest) else { + return DebounceStep::Idle; + }; + let deadline = (latest + GIT_WATCH_DEBOUNCE).min(opened + GIT_WATCH_MAX_DELAY); + match deadline.checked_duration_since(now) { + Some(left) if !left.is_zero() => DebounceStep::Wait(left), + _ => { + self.opened = None; + self.latest = None; + DebounceStep::Fire + } + } + } +} + +/// A claim on one of a host's network slots, released by dropping it. +/// +/// The count has to come back even when nothing lands: `HostOps::run_in` skips +/// its landing closure if the view died first, and a slot released only there +/// leaks for the lifetime of the process. So the guard rides in the *work* +/// closure instead, which the blocking pool runs either way, and the counter +/// is an atomic rather than a field of the global so that dropping it needs no +/// `App` at all. +pub struct NetworkSlot(Arc); + +impl Drop for NetworkSlot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Release); + } +} + +/// Who is holding a repository open. +/// +/// An enum rather than an opaque token because every one of these decides +/// what it wants by looking at the frame it is rendering, not by remembering +/// that it asked. Declaring "this is what I want now" is idempotent; calling +/// `acquire` from `render` would count up forever. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ScmWatcher { + /// The source control panel, while it is the visible tab. + Panel, + /// The file tree, while it is showing a directory inside the repository. + FileTree, + /// The code editor, while it has a file from the repository open. + Editor, +} + +/// Who is watching which repository, and a generation to date results by. +/// +/// The point is the zero: with nobody looking, a repository must cost nothing +/// — no watch, no timer, no `status -uall` in the background. Anything already +/// in flight when the last holder leaves is not cancellable, so it is dated +/// instead: the generation moves and the result is dropped on arrival. +#[derive(Default)] +pub struct GitSubscriptions { + refs: ByHost, + sub_gen: ByHost, + held: HashMap, +} + +impl GitSubscriptions { + /// Take a hold, and report the generation results must carry to be kept. + pub fn acquire(&mut self, host: HostId, root: &Path) -> u64 { + let next = self.count(host, root) + 1; + self.refs.insert(host, root.to_path_buf(), next); + self.generation(host, root) + } + + /// Give one back. The last one out invalidates everything in flight. + pub fn release(&mut self, host: HostId, root: &Path) { + let left = self.count(host, root).saturating_sub(1); + if left > 0 { + self.refs.insert(host, root.to_path_buf(), left); + return; + } + self.refs.remove(host, root); + let next = self.generation(host, root) + 1; + self.sub_gen.insert(host, root.to_path_buf(), next); + } + + pub fn generation(&self, host: HostId, root: &Path) -> u64 { + self.sub_gen.get(host, root).copied().unwrap_or(0) + } + + pub fn count(&self, host: HostId, root: &Path) -> u32 { + self.refs.get(host, root).copied().unwrap_or(0) + } + + pub fn is_subscribed(&self, host: HostId, root: &Path) -> bool { + self.count(host, root) > 0 + } + + /// State what `who` wants right now — safe to call every frame. + /// + /// Returns the repository that just lost its last holder, if any, so the + /// caller can tear down what was keeping it alive. + pub fn declare( + &mut self, + who: ScmWatcher, + target: Option<(HostId, PathBuf)>, + ) -> Option<(HostId, PathBuf)> { + let previous = self.held.get(&who).cloned(); + if previous == target { + return None; + } + match &target { + Some((host, root)) => { + self.acquire(*host, root); + self.held.insert(who, (*host, root.clone())); + } + None => { + self.held.remove(&who); + } + } + let (host, root) = previous?; + self.release(host, &root); + (!self.is_subscribed(host, &root)).then_some((host, root)) + } + + fn clear_host(&mut self, host: HostId) { + self.refs.clear_host(host); + self.sub_gen.clear_host(host); + self.held.retain(|_, (held, _)| *held != host); + } + + fn is_empty(&self) -> bool { + self.refs.is_empty() && self.held.is_empty() + } + + fn subscribed(&self) -> Vec<(HostId, PathBuf)> { + self.refs + .keys() + .map(|(host, root)| (host, root.clone())) + .collect() + } +} + +/// What a probe hands back to the UI thread: [`StatusProbe`], with the +/// decoration index pre-built off-thread and the panic case folded in. +enum ProbeLanding { + Status(Arc, Arc), + NotARepo, + Unreachable, +} + +/// One repository's `.git` watch and the burst it is feeding. +#[derive(Default)] +struct RepoWatch { + sub: Option>, + /// A watch is two round trips to open (`rev-parse`, then `watch`), so the + /// frame after the one that asked must not ask again. + opening: bool, + /// Set when an open came back empty-handed; `unwatched` sits out until it + /// passes, so a host that cannot watch is asked once per [`WATCH_RETRY`], + /// not once per frame. + retry_at: Option, + debounce: Debounce, +} + +#[derive(Default)] +pub struct ScmData { + /// repo root → the last status we read. + status: ByHost>, + /// repo root → the decoration index derived from that status. + index: ByHost>, + /// repo root → a counter bumped by anything that could have changed it. + epoch: ByHost, + /// repo root → the epoch the cached status was read at. + read_at: ByHost, + /// repo root → when a probe last came back *unreachable* — not "not a + /// repository", but "the question could not be asked". The held status + /// stays (stale beats blank), and `is_stale` sits out + /// [`PROBE_FAILURE_RETRY`] so a dead link is not probed at frame rate. + failed_at: ByHost, + probes: InFlight<(HostId, PathBuf)>, + network: ByHost>, + /// repo root → its `.git` watch, once someone is looking. A plain map + /// rather than a [`ByHost`] because this one is mutated in place. + watches: HashMap<(HostId, PathBuf), RepoWatch>, + subs: GitSubscriptions, + /// Bumped by [`ScmData::clear_host`]. A probe that was in flight across a + /// disconnect is holding a picture of a machine we have stopped believing, + /// and neither the epoch nor the generation can say so: the disconnect + /// wipes both back to their defaults, which is exactly what the in-flight + /// read recorded on the way out. + wipe: u64, +} + +impl gpui::Global for ScmData {} + +impl ScmData { + pub fn status_for(&self, host: HostId, root: &Path) -> Option> { + self.status.get(host, root).cloned() + } + + pub fn index_for(&self, host: HostId, root: &Path) -> Option> { + self.index.get(host, root).cloned() + } + + /// Three-valued, and the middle value is the one that matters: `None` for + /// "never looked", `Some(None)` for "looked, and there is no repository + /// there". A directory that is not a repository is a perfectly normal + /// thing for a pane to be sitting in, and without somewhere to record the + /// negative answer the next look asks again — every frame. + pub fn known_status( + &self, + host: HostId, + root: &Path, + ) -> Option>> { + self.read_at.get(host, root)?; + Some(self.status_for(host, root)) + } + + /// Whether this root has been read at all, whatever the answer was. + pub fn probed(&self, host: HostId, root: &Path) -> bool { + self.read_at.get(host, root).is_some() + } + + pub fn epoch(&self, host: HostId, root: &Path) -> u64 { + self.epoch.get(host, root).copied().unwrap_or(0) + } + + /// Whether what we hold was read before the last thing that changed it. + /// A repository we have never probed counts as stale — unless the last + /// attempt could not reach the host and its rest has not passed yet. + pub fn is_stale(&self, host: HostId, root: &Path) -> bool { + let stale = match self.read_at.get(host, root) { + Some(read) => *read < self.epoch(host, root), + None => true, + }; + stale + && !self + .failed_at + .get(host, root) + .is_some_and(|at| at.elapsed() < PROBE_FAILURE_RETRY) + } + + /// Mark a repository changed. Every write, every `.git` watcher event and + /// every command boundary lands here; readers reprobe on their next look. + /// A real change also ends a failure's rest: whatever made the epoch move + /// is evidence the host is alive again. + pub fn bump(&mut self, host: HostId, root: &Path) { + let next = self.epoch(host, root) + 1; + self.epoch.insert(host, root.to_path_buf(), next); + self.failed_at.remove(host, root); + self.probes.invalidate(&(host, root.to_path_buf())); + } + + /// Drop everything for a host that went away, so a reconnect does not show + /// the state the machine was in when it dropped off. + pub fn clear_host(&mut self, host: HostId) { + self.status.clear_host(host); + self.index.clear_host(host); + self.epoch.clear_host(host); + self.read_at.clear_host(host); + self.failed_at.clear_host(host); + self.network.clear_host(host); + self.watches.retain(|(held, _), _| *held != host); + self.subs.clear_host(host); + // In-flight probe bookkeeping too: a probe whose landing never runs + // (its work panicked, say) would otherwise hold `begin` false for + // this key for the life of the process. + self.probes.retain(|(held, _)| *held != host); + self.wipe += 1; + } + + /// Which hosts we are holding anything for. Used to notice the ones that + /// have since disappeared from the registry. + pub fn hosts(&self) -> Vec { + let mut hosts: Vec = self.epoch.keys().map(|(host, _)| host).collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts + } + + /// Claim one of a host's network slots, or `None` when it is already at + /// the ceiling. Drop the returned guard to give it back. + pub fn take_network_slot(&mut self, host: HostId, root: &Path) -> Option { + let counter = match self.network.get(host, root) { + Some(counter) => Arc::clone(counter), + None => { + let counter = Arc::new(AtomicUsize::new(0)); + self.network + .insert(host, root.to_path_buf(), Arc::clone(&counter)); + counter + } + }; + // Only the UI thread hands these out, so read-then-add cannot race + // another claim; the release side is the one that runs anywhere. + if counter.load(Ordering::Acquire) >= MAX_CONCURRENT_NETWORK_OPS { + return None; + } + counter.fetch_add(1, Ordering::AcqRel); + Some(NetworkSlot(counter)) + } + + pub fn network_slots(&self, host: HostId, root: &Path) -> usize { + self.network + .get(host, root) + .map(|c| c.load(Ordering::Acquire)) + .unwrap_or(0) + } + + pub fn subscriptions(&mut self) -> &mut GitSubscriptions { + &mut self.subs + } + + pub fn generation(&self, host: HostId, root: &Path) -> u64 { + self.subs.generation(host, root) + } + + pub fn is_subscribed(&self, host: HostId, root: &Path) -> bool { + self.subs.is_subscribed(host, root) + } + + /// Nobody is holding a repository open and nothing is watching one. + pub fn is_quiet(&self) -> bool { + self.subs.is_empty() && self.watches.is_empty() + } + + /// Repositories that have a holder but no watch, nothing on the way, and + /// no failed attempt still resting. + fn unwatched(&self, now: Instant) -> Vec<(HostId, PathBuf)> { + self.subs + .subscribed() + .into_iter() + .filter(|key| match self.watches.get(key) { + Some(watch) => { + watch.sub.is_none() + && !watch.opening + && watch.retry_at.is_none_or(|at| at <= now) + } + None => true, + }) + .collect() + } + + fn watch_mut(&mut self, host: HostId, root: &Path) -> &mut RepoWatch { + self.watches.entry((host, root.to_path_buf())).or_default() + } + + /// Say a watch is being opened, unless one already is or already exists. + fn begin_watch_open(&mut self, host: HostId, root: &Path) -> bool { + let watch = self.watch_mut(host, root); + if watch.opening || watch.sub.is_some() { + return false; + } + watch.opening = true; + true + } + + fn finish_watch_open( + &mut self, + host: HostId, + root: &Path, + sub: Option>, + now: Instant, + ) { + let watch = self.watch_mut(host, root); + watch.opening = false; + // A failed open rests before the next try; a successful one clears + // any rest a previous failure left behind. + watch.retry_at = sub.is_none().then(|| now + WATCH_RETRY); + watch.sub = sub; + } + + /// Stop everything a repository was costing: the watch closes when the + /// last `Arc` goes, and the burst is cleared so a timer still asleep on it + /// wakes to `Idle`. + fn drop_watch(&mut self, host: HostId, root: &Path) { + self.watches.remove(&(host, root.to_path_buf())); + } + + /// Record an invalidation. `Some(seq)` means a burst opened and the caller + /// owes it a timer. + pub fn note_change(&mut self, host: HostId, root: &Path, now: Instant) -> Option { + self.watch_mut(host, root).debounce.note(now) + } + + pub fn poll_debounce(&mut self, host: HostId, root: &Path, now: Instant) -> DebounceStep { + match self.watches.get_mut(&(host, root.to_path_buf())) { + Some(watch) => watch.debounce.poll(now), + None => DebounceStep::Idle, + } + } + + pub fn burst_seq(&self, host: HostId, root: &Path) -> u64 { + self.watches + .get(&(host, root.to_path_buf())) + .map(|watch| watch.debounce.seq()) + .unwrap_or(0) + } +} + +/// The status the panel draws from, or `None` until the first probe lands. +/// +/// A free function rather than a method because it is read during `render`, +/// where all anyone holds is `&App`. `try_global` because a view test need +/// never have installed one. +pub(crate) fn status_of( + cx: &gpui::App, + host: HostId, + root: &Path, +) -> Option> { + cx.try_global::()?.status_for(host, root) +} + +/// The per-path decoration index the file tree looks up during `render`. +pub(crate) fn index_of(cx: &gpui::App, host: HostId, root: &Path) -> Option> { + cx.try_global::()?.index_for(host, root) +} + +impl Tty7App { + /// Read a repository's status, unless a read is already running or what we + /// hold is current. Safe to call from `render`. + pub(crate) fn scm_refresh(&mut self, host: SharedHost, root: PathBuf, cx: &mut Context) { + let id = host.id(); + let key = (id, root.clone()); + let data = cx.default_global::(); + if !data.is_stale(id, &root) || !data.probes.begin(key.clone()) { + return; + } + let at = data.epoch(id, &root); + let sub_gen = data.generation(id, &root); + let wipe = data.wipe; + + let probe_root = root.clone(); + let this = cx.weak_entity(); + HostOps::run_detached( + host, + cx, + move |h| { + // `catch_unwind` because a panic on the pool thread would skip + // the landing entirely — and with it `probes.finish`, wedging + // this repository's refresh for the life of the process. + let probe = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + probe_status(h, &probe_root) + })); + match probe { + Ok(StatusProbe::Status(status)) => { + let index = StatusIndex::build(&status); + ProbeLanding::Status(Arc::new(*status), Arc::new(index)) + } + Ok(StatusProbe::NotARepo) => ProbeLanding::NotARepo, + Ok(StatusProbe::Unreachable) | Err(_) => ProbeLanding::Unreachable, + } + }, + move |cx, result| { + let data = cx.default_global::(); + // `finish` says whether the epoch held still while this was in + // flight. It usually did; when it did not, the result is still + // worth showing (stale beats blank) but something has to ask + // again, and nothing else will — the trigger that bumped found + // this probe already running and declined to start its own. + let superseded = !data.probes.finish(&key); + if data.wipe != wipe || data.generation(id, &root) != sub_gen { + return; + } + // Only a definitive answer counts as a read; an unreachable + // host leaves what is cached (stale beats blank) and rests + // before the next try — see `is_stale`. + let definitive = !matches!(result, ProbeLanding::Unreachable); + let mut not_a_repo = false; + let changed = match result { + ProbeLanding::Status(status, index) => { + let same = data + .status + .get(id, root.as_path()) + .is_some_and(|held| **held == *status); + data.status.insert(id, root.clone(), status); + data.index.insert(id, root.clone(), index); + !same + } + // Not a repository — a perfectly ordinary answer, and one + // that has to be recorded like any other. `read_at` below + // is what stops the next frame asking again: a pane whose + // cwd is an ordinary directory would otherwise spawn a + // `rev-parse` per frame, forever. + ProbeLanding::NotARepo => { + not_a_repo = true; + let held = data.status.remove(id, root.as_path()).is_some(); + data.index.remove(id, root.as_path()); + held + } + ProbeLanding::Unreachable => { + data.failed_at.insert(id, root.clone(), Instant::now()); + false + } + }; + if definitive { + data.failed_at.remove(id, root.as_path()); + data.read_at.insert(id, root.clone(), at); + } + if not_a_repo { + // Every cwd that resolved to this root must re-ask, or the + // panel keeps drawing the Loading state of a repository + // that is gone (`rm -rf .git` being the honest test). + let _ = this.update(cx, |app, _| app.scm.forget_root(id, &root)); + } + // `run_detached` lands with an `App` and no view, and writing + // a global marks nothing dirty, so without this the panel and + // the decorations wait for the next unrelated repaint. + // + // Only when something actually moved, though. Most probes + // confirm what is already on screen — a re-read after a write + // that touched another repository, or the "still not a + // repository" answer for an ordinary directory — and repainting + // for those would put a frame behind every file the tree + // notices changing. + if changed { + cx.refresh_windows(); + } + if superseded { + // Through the debounce, not straight back into a probe: + // during sustained churn on a repository whose status read + // outlives the event interval, a direct relaunch runs + // probes back to back for the whole of it. The bump-and- + // wait path coalesces the retry with whatever is still + // landing. + let _ = this.update(cx, |app, cx| app.scm_invalidate(id, &root, cx)); + } + }, + ); + } + + /// The one way to say "this repository changed". + /// + /// Every source lands here — the `.git` watch, a write we just made, the + /// file tree, the editor, a command boundary — so that a `git add` and the + /// watcher event it provokes cost one probe between them rather than two. + /// + /// With nobody subscribed the epoch bump is the whole job: the repository + /// is now marked stale and whoever opens the panel next pays for one read, + /// which is much better than running `status -uall` for an empty room. + pub(crate) fn scm_invalidate(&mut self, host: HostId, root: &Path, cx: &mut Context) { + let data = cx.default_global::(); + data.bump(host, root); + if !data.is_subscribed(host, root) { + return; + } + let Some(seq) = data.note_change(host, root, Instant::now()) else { + return; + }; + let sub_gen = data.generation(host, root); + self.scm_debounce(host, root.to_path_buf(), seq, sub_gen, cx); + } + + /// As [`Tty7App::scm_invalidate`], for a caller that knows a directory + /// rather than a repository — which is every caller outside this module. + pub(crate) fn scm_invalidate_cwd(&mut self, host: HostId, cwd: &Path, cx: &mut Context) { + let Some(root) = cx + .try_global::() + .and_then(|cache| cache.repo_root_for(host, cwd)) + .map(Path::to_path_buf) + else { + return; + }; + self.scm_invalidate(host, &root, cx); + } + + /// Sit on the burst until it goes quiet, then probe once. + fn scm_debounce( + &mut self, + host: HostId, + root: PathBuf, + seq: u64, + sub_gen: u64, + cx: &mut Context, + ) { + cx.spawn(async move |this, cx| { + loop { + let step = this + .update(cx, |_app, cx| { + let data = cx.default_global::(); + // Two ways to be the wrong timer: the burst we were + // started for already fired and a newer one opened, or + // the last holder let go while we slept. + if data.burst_seq(host, &root) != seq + || data.generation(host, &root) != sub_gen + { + return DebounceStep::Idle; + } + data.poll_debounce(host, &root, Instant::now()) + }) + .unwrap_or(DebounceStep::Idle); + match step { + DebounceStep::Idle => return, + DebounceStep::Wait(left) => cx.background_executor().timer(left).await, + DebounceStep::Fire => break, + } + } + let _ = this.update(cx, |app, cx| { + if cx.default_global::().generation(host, &root) != sub_gen { + return; + } + let Some(shared) = crate::ui::host_registry::HostRegistry::get(cx, host) else { + return; + }; + app.scm_refresh(shared, root, cx); + cx.notify(); + }); + }) + .detach(); + } + + /// Reconcile who is watching what. Called once per frame. + /// + /// Everything here is a property of the frame — is the panel the visible + /// tab, which repository is the active pane in, is that host still up — so + /// it is stated rather than remembered, and running it twice costs nothing. + pub(crate) fn scm_sync_watchers(&mut self, window: &Window, cx: &mut Context) { + self.scm_forget_lost_hosts(cx); + + let wanted = [ + (ScmWatcher::Panel, self.scm_panel_target(window, cx)), + (ScmWatcher::FileTree, self.scm_tree_target(window, cx)), + (ScmWatcher::Editor, self.scm_editor_target(cx)), + ]; + // Nothing to watch and nothing being watched, which is every frame of + // a window whose panel is on another tab. Taking the global mutably + // here would queue a global-observer effect per frame for no reason. + if wanted.iter().all(|(_, t)| t.is_none()) + && cx.try_global::().is_none_or(ScmData::is_quiet) + { + return; + } + for (who, target) in wanted { + let dropped = cx + .default_global::() + .subscriptions() + .declare(who, target); + if let Some((host, root)) = dropped { + cx.default_global::().drop_watch(host, &root); + } + } + + for (host, root) in cx.default_global::().unwatched(Instant::now()) { + let Some(shared) = crate::ui::host_registry::HostRegistry::get(cx, host) else { + continue; + }; + self.scm_open_watch(shared.clone(), root.clone(), cx); + self.scm_refresh(shared, root, cx); + } + } + + /// The repository the panel is showing, while it is showing one. + /// + /// Prefers whatever the panel itself settled on; falls back to the repo + /// the active pane is sitting in, which is what the panel will pick anyway + /// and is already known from the cheap tab-badge probe. + fn scm_panel_target(&self, window: &Window, cx: &gpui::App) -> Option<(HostId, PathBuf)> { + use crate::core::config::RightPanelTab; + + if !self.right_panel_visible || self.right_panel_tab != RightPanelTab::Scm { + return None; + } + if let Some(repo) = self.scm.active_repo() { + return Some((repo.host, repo.root.clone())); + } + let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; + let view = leaf.read(cx); + let host = view.host_id(); + let root = cx + .try_global::()? + .repo_root_for(host, view.git_status_cwd()?)?; + Some((host, root.to_path_buf())) + } + + /// The repository the file tree is decorating, while it is on screen. + /// + /// The tree can be rooted at several repositories at once but a watcher + /// holds one; the active pane's is the one whose decorations the user is + /// looking at, and it is the one the panel would pick too — so the two + /// subscriptions usually collapse onto the same repository and cost one + /// watch between them. + fn scm_tree_target(&self, window: &Window, cx: &gpui::App) -> Option<(HostId, PathBuf)> { + if !self.file_tree_on_screen(cx) { + return None; + } + let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; + let view = leaf.read(cx); + let host = view.host_id(); + let root = cx + .try_global::()? + .repo_root_for(host, view.git_status_cwd()?)?; + Some((host, root.to_path_buf())) + } + + /// The repository the focused editor's file belongs to. + /// + /// Only the focused one: an editor on a background tab is not showing + /// anyone a gutter, and holding a watch per open file would put a `status` + /// probe behind every repository the user has visited this session. + fn scm_editor_target(&self, cx: &gpui::App) -> Option<(HostId, PathBuf)> { + let code = self.tabs.get(self.active)?.code.as_ref()?; + if !code.visible { + return None; + } + let open = code.active_file()?; + let host = self.spawn_host(cx); + let root = cx + .try_global::()? + .repo_root_for(host, open.path.parent()?)?; + Some((host, root.to_path_buf())) + } + + /// Forget hosts that have left the registry. + /// + /// A dropped SSH link is the case that matters: without this, the panel + /// keeps showing the branch and the file list the machine had at the + /// moment it fell off, with no way to tell that from live data. + fn scm_forget_lost_hosts(&mut self, cx: &mut Context) { + let held = match cx.try_global::() { + Some(data) => data.hosts(), + None => return, + }; + if held.is_empty() { + return; + } + let live = crate::ui::host_registry::HostRegistry::ids(cx); + for host in held.into_iter().filter(|h| !live.contains(h)) { + cx.default_global::().clear_host(host); + cx.default_global::() + .clear_host(host); + // The panel's own per-cwd caches too — `roots` grows one entry + // per directory ever visited on the dead link otherwise. + self.scm.forget_host(host); + } + } + + /// Open a `.git` watch: resolve the directories, then subscribe to them. + /// + /// Both halves are one background job because both are round trips on a + /// remote workspace, and the directory list is only wanted in order to + /// pass it straight to `watch`. + fn scm_open_watch(&mut self, host: SharedHost, root: PathBuf, cx: &mut Context) { + let id = host.id(); + if !cx.default_global::().begin_watch_open(id, &root) { + return; + } + let sub_gen = cx.default_global::().generation(id, &root); + let wipe = cx.default_global::().wipe; + let probe_root = root.clone(); + HostOps::run( + host, + cx, + move |h| { + // `catch_unwind` for the same reason the status probe carries + // it: a panic here would skip the landing, and with it + // `finish_watch_open` — `opening` would stay true forever. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let dirs = scm_watch_dirs(h, &probe_root)?; + match h.watch(&dirs) { + Ok(sub) => Some(Arc::new(sub)), + Err(e) => { + log::warn!("source control: no watch for {probe_root:?}: {e}"); + None + } + } + })) + .unwrap_or(None) + }, + move |app, sub: Option>, cx| { + app.scm_watch_opened(id, root, sub_gen, wipe, sub, cx) + }, + ); + } + + fn scm_watch_opened( + &mut self, + host: HostId, + root: PathBuf, + sub_gen: u64, + wipe: u64, + sub: Option>, + cx: &mut Context, + ) { + let data = cx.default_global::(); + // Letting go while the watch was opening leaves the only `Arc` here, + // so returning closes it. `wipe` closes the one gap `generation` + // cannot: a disconnect resets generations to their default, so a + // watch opened against the *previous* connection could otherwise be + // installed for the re-subscribed repository. + if data.wipe != wipe + || data.generation(host, &root) != sub_gen + || !data.is_subscribed(host, &root) + { + data.finish_watch_open(host, &root, None, Instant::now()); + return; + } + let events = sub.as_ref().map(|sub| sub.events().clone()); + data.finish_watch_open(host, &root, sub, Instant::now()); + let Some(events) = events else { + return; + }; + cx.spawn(async move |app, cx| { + // The batch is not read. Anything at all under `.git` means the + // answer to "what does this repository look like" may have moved, + // and working out which paths imply which parts of the answer + // would be a second, worse copy of what `git status` already does. + while events.recv().await.is_ok() { + if app + .update(cx, |app, cx| app.scm_invalidate(host, &root, cx)) + .is_err() + { + return; + } + } + }) + .detach(); + } + + /// Change the repository, then let everyone notice. + /// + /// Confirmation of a destructive operation is the caller's job, not this + /// one's — see [`GitOp::destructive`]. `run_git_op` has to stay callable + /// from a flow that already asked, and from a test. + pub(crate) fn run_git_op( + &mut self, + host: SharedHost, + root: PathBuf, + op: GitOp, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(status) = status_of(cx, host.id(), &root) else { + return; + }; + let head = status.head.clone(); + let id = host.id(); + + // The guard rides into the work closure rather than being released in + // the landing one: `run_in` skips landing entirely if the view died + // first, and a slot released only there is a slot lost for good. + let slot = if op.is_network() { + match cx.default_global::().take_network_slot(id, &root) { + Some(slot) => Some(slot), + None => { + // Said out loud: a swallowed click on Push looks exactly + // like a push that finished instantly. + gpui_component::WindowExt::push_notification( + window, + crate::ui::i18n::t(crate::ui::i18n::L10nKey::ScmNetworkBusy).to_string(), + cx, + ); + return; + } + } + } else { + None + }; + + // Armed at dispatch, not when the button was pressed: a confirmation + // the user cancels must leave nothing armed, or the next unrelated + // HEAD move would clear a message that was never committed. See + // `scm_commit_landed`. + let was_commit = matches!(op, GitOp::Commit { .. }); + if let GitOp::Commit { message, .. } = &op { + self.scm.committing = Some(( + crate::ui::scm::state::RepoKey { + host: id, + root: root.clone(), + }, + head.clone(), + message.clone(), + )); + } + + let op_root = root.clone(); + HostOps::run_in( + host, + window, + cx, + move |h| { + let outcome = run_op(h, &op_root, &op, &head); + drop(slot); + outcome + }, + move |app, result, window, cx| { + // Onto the same bus as everything else: the watcher event this + // write is about to cause arrives inside the debounce window + // and the two of them cost one probe. + app.scm_invalidate(id, &root, cx); + let ok = result.is_ok(); + if was_commit && !ok { + app.scm_commit_failed(id, &root); + } + app.on_git_op_done(result, window, cx); + // The second half of a compound verb starts only now, against + // the repository this operation produced — never alongside it. + if ok { + if let Some(follow) = then { + app.scm_follow_up(id, root, follow, window, cx); + } + } + }, + ); + } + + fn on_git_op_done( + &mut self, + result: Result, + window: &mut Window, + cx: &mut Context, + ) { + if let Err(err) = result { + self.report_git_op_error(&err, window, cx); + } + cx.notify(); + } + + /// Say what went wrong, and — when the answer is a credential a window + /// cannot supply — offer the one thing tty7 has that a GUI does not: a + /// real terminal to run it in. + fn report_git_op_error( + &mut self, + err: &GitOpError, + window: &mut Window, + cx: &mut Context, + ) { + use crate::ui::i18n::{L10nKey, t_fmt}; + let text = t_fmt( + L10nKey::HostOpsError, + &[("context", err.op), ("error", &err.message)], + ); + gpui_component::WindowExt::push_notification(window, text, cx); + if err.kind == GitOpErrorKind::AuthRequired { + log::info!( + "git {} needs a credential; re-run in a pane: {}", + err.op, + shell_quote(&err.rerun_argv), + ); + } + } +} + +/// Ask a host where a repository's `.git` is, and what to watch inside it. +/// +/// Two round trips: `rev-parse` for the two directories, then one `read_dir` +/// for the namespaces under `refs/heads`. The list is resolved once, when the +/// watch opens, and not maintained afterwards — a branch created in a +/// namespace nobody had yet still writes `` and eventually +/// `packed-refs`, both of which are watched, and the panel re-resolves the +/// whole set the next time it is opened. +fn scm_watch_dirs(host: &dyn Host, root: &Path) -> Option> { + let out = host + .git( + root, + &[ + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + ], + ) + .ok()?; + if !out.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut lines = text.lines().map(|l| l.trim_end_matches(['\n', '\r'])); + let git_dir = PathBuf::from(lines.next().filter(|l| !l.is_empty())?); + let common_dir = lines + .next() + .filter(|l| !l.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| git_dir.clone()); + + let sep = host.separator(); + let heads = tty7_core::host::default_join( + &tty7_core::host::default_join(&common_dir, "refs", sep), + "heads", + sep, + ); + let namespaces: Vec = host + .read_dir(&heads, None) + .unwrap_or_default() + .into_iter() + .filter(|entry| entry.is_dir) + .map(|entry| entry.name) + .collect(); + Some(git_watch_dirs(sep, &git_dir, &common_dir, &namespaces)) +} + +/// Render an argv as a line a shell will read back identically. +/// +/// Single quotes with the `'\''` escape: the only characters that survive +/// unquoted are the ones that cannot mean anything else. +pub(crate) fn shell_quote(argv: &[String]) -> String { + argv.iter() + .map(|arg| { + let safe = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+".contains(&b)); + if safe { + arg.clone() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> PathBuf { + PathBuf::from("/repo") + } + + #[test] + fn a_repository_nobody_has_read_counts_as_stale() { + let data = ScmData::default(); + assert!(data.is_stale(HostId::LOCAL, &root())); + assert_eq!(data.epoch(HostId::LOCAL, &root()), 0); + } + + #[test] + fn a_bump_makes_a_fresh_read_stale_again() { + let mut data = ScmData::default(); + data.read_at.insert(HostId::LOCAL, root(), 0); + assert!(!data.is_stale(HostId::LOCAL, &root())); + + data.bump(HostId::LOCAL, &root()); + assert!( + data.is_stale(HostId::LOCAL, &root()), + "a write has to send the next look back to git" + ); + } + + #[test] + fn epochs_do_not_leak_between_hosts() { + let mut data = ScmData::default(); + let other = HostId::from_connection_key("somewhere-else"); + data.bump(HostId::LOCAL, &root()); + assert_eq!(data.epoch(HostId::LOCAL, &root()), 1); + assert_eq!( + data.epoch(other, &root()), + 0, + "the same path on two machines is two repositories" + ); + } + + #[test] + fn clearing_a_host_forgets_what_it_looked_like() { + let mut data = ScmData::default(); + data.bump(HostId::LOCAL, &root()); + data.read_at.insert(HostId::LOCAL, root(), 1); + data.clear_host(HostId::LOCAL); + assert!( + data.is_stale(HostId::LOCAL, &root()), + "a reconnect must not show the state from before the drop" + ); + } + + #[test] + fn shell_quote_leaves_a_plain_argv_alone() { + let argv = ["git", "push", "origin", "main"].map(String::from); + assert_eq!(shell_quote(&argv), "git push origin main"); + } + + #[test] + fn shell_quote_survives_a_round_trip_through_a_shell() { + let argv = [ + "git".to_string(), + "commit".to_string(), + "-m".to_string(), + "it's a \"quoted\" $message; rm -rf /".to_string(), + ]; + assert_eq!( + shell_quote(&argv), + r#"git commit -m 'it'\''s a "quoted" $message; rm -rf /'"# + ); + } + + #[test] + fn shell_quote_does_not_leave_an_empty_argument_bare() { + assert_eq!(shell_quote(&["git".into(), String::new()]), "git ''"); + } + + // -- what to watch ---------------------------------------------------- + + fn names(dirs: &[PathBuf]) -> Vec { + dirs.iter().map(|d| d.display().to_string()).collect() + } + + #[test] + fn a_plain_repository_watches_its_git_dir_and_the_three_ref_roots() { + let git_dir = PathBuf::from("/repo/.git"); + let dirs = git_watch_dirs('/', &git_dir, &git_dir, &[]); + assert_eq!( + names(&dirs), + [ + "/repo/.git", + "/repo/.git/refs/heads", + "/repo/.git/refs/remotes", + "/repo/.git/refs/tags", + ], + "the common dir is the git dir here, so it must not be listed twice" + ); + } + + #[test] + fn a_linked_worktree_watches_both_of_its_directories() { + // HEAD and index live under the worktree's own git dir; packed-refs + // and every branch live in the one it borrows. + let git_dir = PathBuf::from("/repo/.git/worktrees/feat"); + let common = PathBuf::from("/repo/.git"); + let dirs = git_watch_dirs('/', &git_dir, &common, &["feat".into()]); + assert_eq!( + names(&dirs), + [ + "/repo/.git/worktrees/feat", + "/repo/.git", + "/repo/.git/refs/heads", + "/repo/.git/refs/remotes", + "/repo/.git/refs/tags", + "/repo/.git/refs/heads/feat", + ] + ); + } + + #[test] + fn a_windows_host_gets_its_own_separator() { + let git_dir = PathBuf::from(r"C:\src\repo\.git"); + let dirs = git_watch_dirs('\\', &git_dir, &git_dir, &[]); + assert!( + names(&dirs).contains(&r"C:\src\repo\.git\refs\heads".to_string()), + "got {:?}", + names(&dirs) + ); + } + + #[test] + fn too_many_branch_namespaces_are_dropped_rather_than_watched() { + let git_dir = PathBuf::from("/repo/.git"); + let many: Vec = (0..MAX_WATCHED_REF_DIRS + 1) + .map(|i| format!("ns{i}")) + .collect(); + let dirs = git_watch_dirs('/', &git_dir, &git_dir, &many); + assert_eq!(dirs.len(), 4, "packed-refs and the git dir still cover it"); + + let just_enough = &many[..MAX_WATCHED_REF_DIRS]; + let dirs = git_watch_dirs('/', &git_dir, &git_dir, just_enough); + assert_eq!(dirs.len(), 4 + MAX_WATCHED_REF_DIRS); + } + + // -- debounce --------------------------------------------------------- + + fn at(base: Instant, ms: u64) -> Instant { + base + Duration::from_millis(ms) + } + + #[test] + fn a_burst_inside_the_window_costs_one_probe() { + let t0 = Instant::now(); + let mut debounce = Debounce::default(); + + assert_eq!(debounce.note(t0), Some(1), "the first event opens a burst"); + for ms in [40, 90, 150] { + assert_eq!( + debounce.note(at(t0, ms)), + None, + "an open burst already has a timer" + ); + } + assert_eq!( + debounce.poll(at(t0, 300)), + DebounceStep::Wait(Duration::from_millis(100)), + "the last event at 150ms moved the deadline out to 400ms" + ); + assert_eq!(debounce.poll(at(t0, 400)), DebounceStep::Fire); + assert_eq!( + debounce.poll(at(t0, 400)), + DebounceStep::Idle, + "firing closes the burst; four events cost one probe" + ); + } + + #[test] + fn an_unending_burst_still_fires_at_the_ceiling() { + let t0 = Instant::now(); + let mut debounce = Debounce::default(); + debounce.note(t0); + + // A checkout writing `.git` every 100ms would push the quiet deadline + // out forever; the ceiling is what stops the panel looking hung. + let mut fired = None; + for ms in (100..=2000).step_by(100) { + debounce.note(at(t0, ms)); + if debounce.poll(at(t0, ms)) == DebounceStep::Fire { + fired = Some(ms); + break; + } + } + assert_eq!(fired, Some(1000), "GIT_WATCH_MAX_DELAY is the backstop"); + } + + #[test] + fn a_new_burst_gets_a_new_sequence_so_the_old_timer_stops() { + let t0 = Instant::now(); + let mut debounce = Debounce::default(); + assert_eq!(debounce.note(t0), Some(1)); + assert_eq!(debounce.poll(at(t0, 250)), DebounceStep::Fire); + assert_eq!(debounce.note(at(t0, 500)), Some(2)); + assert_eq!(debounce.seq(), 2); + } + + #[test] + fn a_write_and_the_watcher_event_it_causes_probe_once() { + // `git add` writes `.git/index`, so the watcher fires a few + // milliseconds after `run_git_op` has already said so itself. Both go + // through the same bus, so both land in one window. + let t0 = Instant::now(); + let mut data = ScmData::default(); + data.subscriptions().acquire(HostId::LOCAL, &root()); + + assert_eq!( + data.note_change(HostId::LOCAL, &root(), t0), + Some(1), + "the write announces itself" + ); + assert_eq!( + data.note_change(HostId::LOCAL, &root(), at(t0, 30)), + None, + "the watcher event it provoked joins the same burst" + ); + + let mut fires = 0; + for ms in [100, 200, 280, 400, 800] { + if data.poll_debounce(HostId::LOCAL, &root(), at(t0, ms)) == DebounceStep::Fire { + fires += 1; + } + } + assert_eq!(fires, 1, "one probe, not two — and no oscillation after it"); + } + + // -- subscriptions ---------------------------------------------------- + + #[test] + fn the_last_holder_out_moves_the_generation() { + let mut subs = GitSubscriptions::default(); + let gen0 = subs.acquire(HostId::LOCAL, &root()); + assert_eq!(subs.acquire(HostId::LOCAL, &root()), gen0, "still the same"); + assert_eq!(subs.count(HostId::LOCAL, &root()), 2); + + subs.release(HostId::LOCAL, &root()); + assert_eq!( + subs.generation(HostId::LOCAL, &root()), + gen0, + "one holder left, so anything in flight is still wanted" + ); + subs.release(HostId::LOCAL, &root()); + assert!(!subs.is_subscribed(HostId::LOCAL, &root())); + assert_ne!( + subs.generation(HostId::LOCAL, &root()), + gen0, + "a result landing now belongs to a panel nobody is looking at" + ); + + // …and taking a hold again does not resurrect the old generation. + assert_ne!(subs.acquire(HostId::LOCAL, &root()), gen0); + } + + #[test] + fn releasing_a_repository_nobody_holds_does_not_underflow() { + let mut subs = GitSubscriptions::default(); + subs.release(HostId::LOCAL, &root()); + assert_eq!(subs.count(HostId::LOCAL, &root()), 0); + } + + #[test] + fn declaring_the_same_target_twice_does_not_count_twice() { + let mut subs = GitSubscriptions::default(); + let here = Some((HostId::LOCAL, root())); + for _ in 0..30 { + assert_eq!(subs.declare(ScmWatcher::Panel, here.clone()), None); + } + assert_eq!( + subs.count(HostId::LOCAL, &root()), + 1, + "render runs every frame; a hold is a statement, not an event" + ); + + subs.declare(ScmWatcher::FileTree, here.clone()); + assert_eq!(subs.count(HostId::LOCAL, &root()), 2); + assert_eq!( + subs.declare(ScmWatcher::Panel, None), + None, + "the file tree is still looking" + ); + assert_eq!( + subs.declare(ScmWatcher::FileTree, None), + Some((HostId::LOCAL, root())), + "the last one out reports the repository to tear down" + ); + } + + #[test] + fn moving_a_subscriber_to_another_repository_releases_the_first() { + let mut subs = GitSubscriptions::default(); + let other = PathBuf::from("/elsewhere"); + subs.declare(ScmWatcher::Panel, Some((HostId::LOCAL, root()))); + assert_eq!( + subs.declare(ScmWatcher::Panel, Some((HostId::LOCAL, other.clone()))), + Some((HostId::LOCAL, root())) + ); + assert_eq!(subs.count(HostId::LOCAL, &root()), 0); + assert_eq!(subs.count(HostId::LOCAL, &other), 1); + } + + #[test] + fn an_unsubscribed_repository_costs_nothing_but_an_epoch() { + // The invalidation still has to be recorded — the next subscriber must + // find it stale — but nothing may be scheduled for an empty room. + let mut data = ScmData::default(); + data.bump(HostId::LOCAL, &root()); + assert!(data.is_stale(HostId::LOCAL, &root())); + assert!(!data.is_subscribed(HostId::LOCAL, &root())); + assert_eq!(data.burst_seq(HostId::LOCAL, &root()), 0); + assert_eq!( + data.poll_debounce(HostId::LOCAL, &root(), Instant::now()), + DebounceStep::Idle + ); + } + + #[test] + fn only_repositories_without_a_watch_are_asked_for_one() { + let now = Instant::now(); + let mut data = ScmData::default(); + data.subscriptions().acquire(HostId::LOCAL, &root()); + assert_eq!(data.unwatched(now), vec![(HostId::LOCAL, root())]); + + assert!(data.begin_watch_open(HostId::LOCAL, &root())); + assert!( + !data.begin_watch_open(HostId::LOCAL, &root()), + "the frame after the one that asked must not ask again" + ); + assert!(data.unwatched(now).is_empty()); + + // A watch that failed to open rests before the next try — the retry + // used to run at frame rate, one host round trip per render, forever. + data.finish_watch_open(HostId::LOCAL, &root(), None, now); + assert!( + data.unwatched(now).is_empty(), + "the frame after a failure must not retry it" + ); + assert_eq!( + data.unwatched(now + WATCH_RETRY), + vec![(HostId::LOCAL, root())], + "…but once the rest has passed, it is asked for again" + ); + } + + #[test] + fn dropping_a_watch_forgets_the_burst_with_it() { + let mut data = ScmData::default(); + data.subscriptions().acquire(HostId::LOCAL, &root()); + data.note_change(HostId::LOCAL, &root(), Instant::now()); + data.subscriptions().release(HostId::LOCAL, &root()); + data.drop_watch(HostId::LOCAL, &root()); + assert_eq!( + data.poll_debounce(HostId::LOCAL, &root(), Instant::now()), + DebounceStep::Idle, + "a timer still asleep on that burst has to wake up to nothing" + ); + } + + // -- network slots ---------------------------------------------------- + + #[test] + fn a_network_slot_comes_back_however_the_operation_ends() { + let mut data = ScmData::default(); + let first = data.take_network_slot(HostId::LOCAL, &root()).unwrap(); + let second = data.take_network_slot(HostId::LOCAL, &root()).unwrap(); + assert_eq!(data.network_slots(HostId::LOCAL, &root()), 2); + assert!( + data.take_network_slot(HostId::LOCAL, &root()).is_none(), + "the third push has to wait, or keepalive misses its deadline" + ); + + // The failure path is the one that used to leak: the guard travels + // with the work, so it is released whether or not anything lands. + drop(first); + assert_eq!(data.network_slots(HostId::LOCAL, &root()), 1); + assert!(data.take_network_slot(HostId::LOCAL, &root()).is_some()); + drop(second); + assert_eq!(data.network_slots(HostId::LOCAL, &root()), 0); + } + + #[test] + fn network_slots_are_counted_per_repository_and_per_host() { + let mut data = ScmData::default(); + let other = HostId::from_connection_key("ssh-direct:me@box:22"); + let _a = data.take_network_slot(HostId::LOCAL, &root()).unwrap(); + let _b = data.take_network_slot(HostId::LOCAL, &root()).unwrap(); + assert!(data.take_network_slot(HostId::LOCAL, &root()).is_none()); + assert!( + data.take_network_slot(other, &root()).is_some(), + "a busy laptop must not stop a push on a remote box" + ); + assert!( + data.take_network_slot(HostId::LOCAL, Path::new("/other")) + .is_some() + ); + } + + // -- disconnect ------------------------------------------------------- + + #[test] + fn clearing_a_host_takes_the_watch_and_the_subscription_with_it() { + let mut data = ScmData::default(); + let gone = HostId::from_connection_key("ssh-direct:me@box:22"); + data.subscriptions().acquire(gone, &root()); + data.begin_watch_open(gone, &root()); + data.note_change(gone, &root(), Instant::now()); + data.status.insert(gone, root(), Arc::new(fake_status())); + data.read_at.insert(gone, root(), 7); + data.subscriptions().acquire(HostId::LOCAL, &root()); + + let wipe = data.wipe; + data.clear_host(gone); + + assert!(data.status_for(gone, &root()).is_none()); + assert!(!data.is_subscribed(gone, &root())); + assert!(data.is_stale(gone, &root())); + assert_eq!( + data.poll_debounce(gone, &root(), Instant::now()), + DebounceStep::Idle + ); + assert_ne!(data.wipe, wipe, "a probe in flight across the drop is void"); + assert!( + data.is_subscribed(HostId::LOCAL, &root()), + "the same path on this machine is a different repository" + ); + } + + #[test] + fn the_tracked_host_list_is_what_the_sweep_looks_at() { + let mut data = ScmData::default(); + let remote = HostId::from_connection_key("ssh-direct:me@box:22"); + assert!(data.hosts().is_empty(), "an idle app sweeps nothing"); + data.bump(HostId::LOCAL, &root()); + data.bump(remote, &root()); + data.bump(remote, Path::new("/other")); + let mut hosts = data.hosts(); + hosts.sort_unstable(); + let mut want = vec![HostId::LOCAL, remote]; + want.sort_unstable(); + assert_eq!(hosts, want, "each host once, however many repositories"); + } + + fn fake_status() -> WorkingTreeStatus { + use crate::core::git::status::HeadState; + WorkingTreeStatus { + root: root(), + home: root(), + head: HeadState::Detached { + oid: "0".repeat(40), + }, + upstream: None, + ahead_behind: None, + entries: Vec::new(), + total_entries: 0, + truncated: false, + stash_count: 0, + operation: None, + prefilled_message: None, + } + } + + // -- what a probe leaves behind --------------------------------------- + + /// Landing a result, with the bookkeeping `scm_refresh` does around it. + fn land(data: &mut ScmData, root: &Path, status: Option) { + let at = data.epoch(HostId::LOCAL, root); + match status { + Some(status) => { + data.status + .insert(HostId::LOCAL, root.to_path_buf(), Arc::new(status)); + } + None => { + data.status.remove(HostId::LOCAL, root); + } + } + data.read_at.insert(HostId::LOCAL, root.to_path_buf(), at); + } + + #[test] + fn a_directory_that_is_not_a_repository_is_only_asked_once() { + let mut data = ScmData::default(); + let plain = Path::new("/tmp/notes"); + assert_eq!(data.known_status(HostId::LOCAL, plain), None); + + land(&mut data, plain, None); + assert_eq!( + data.known_status(HostId::LOCAL, plain), + Some(None), + "'there is no repository here' is an answer, not a missing one" + ); + assert!( + !data.is_stale(HostId::LOCAL, plain), + "otherwise every frame spawns another rev-parse for a plain directory" + ); + + data.bump(HostId::LOCAL, plain); + assert!( + data.is_stale(HostId::LOCAL, plain), + "…until something moves" + ); + } + + #[test] + fn a_repository_that_goes_away_stops_being_reported_as_one() { + let mut data = ScmData::default(); + land(&mut data, &root(), Some(fake_status())); + assert!( + data.known_status(HostId::LOCAL, &root()) + .flatten() + .is_some() + ); + + data.bump(HostId::LOCAL, &root()); + land(&mut data, &root(), None); + assert_eq!( + data.known_status(HostId::LOCAL, &root()), + Some(None), + "the last good answer must not outlive the repository" + ); + } + + #[test] + fn an_app_with_nothing_open_does_not_touch_the_global() { + let data = ScmData::default(); + assert!(data.is_quiet(), "no holders, no watches, nothing to sync"); + } + + // -- against a real repository ---------------------------------------- + + /// The same pin list `tty7_core::core::git::test_support` keeps for the + /// four modules over there. It cannot be shared with them: those helpers + /// are `#[cfg(test)]`, so they do not exist in the `tty7-core` this crate + /// links against. Identity and signing because a runner has neither and a + /// developer may have signing on; line endings because Git for Windows + /// puts `core.autocrlf=true` in its system config, and a fixture whose + /// bytes depend on the machine is not a fixture. + fn run(host: &dyn Host, cwd: &Path, args: &[&str]) -> bool { + let mut full = vec![ + "-c", + "user.name=tty7", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + ]; + full.extend_from_slice(args); + host.git(cwd, &full).map(|o| o.success()).unwrap_or(false) + } + + #[test] + fn a_linked_worktree_resolves_to_both_of_its_real_directories() { + // The layout rules are covered above; this is here because the two + // directories come out of one `rev-parse`, and a repository is the + // only thing that can say whether we asked it the right question. + let host = tty7_core::host::local::LocalHost::new(); + let Ok(scratch) = tempfile::tempdir() else { + return; + }; + // `git rev-parse` reports real paths, and on macOS the temp directory + // is reached through the `/var` → `/private/var` symlink. + let base = std::fs::canonicalize(scratch.path()).unwrap(); + let repo = base.join("main"); + std::fs::create_dir(&repo).unwrap(); + if !run(&*host, &repo, &["init", "--quiet"]) { + return; // no git on this machine + } + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + assert!(run(&*host, &repo, &["add", "-A"])); + assert!(run(&*host, &repo, &["commit", "--quiet", "-m", "base"])); + + let plain = spelled(&scm_watch_dirs(&*host, &repo).expect("a repository is here")); + assert_eq!(plain[0], one_spelling(&repo.join(".git"))); + assert!(plain.contains(&one_spelling(&repo.join(".git").join("refs").join("heads")))); + + let linked = base.join("wt"); + if !run( + &*host, + &repo, + &["worktree", "add", "-q", "-b", "feat/x", "../wt"], + ) { + return; // git too old for worktrees + } + let dirs = spelled(&scm_watch_dirs(&*host, &linked).expect("the worktree is a repository")); + assert!( + dirs[0].starts_with(&one_spelling(&repo.join(".git").join("worktrees"))), + "HEAD and index live in the worktree's own git dir, got {dirs:?}" + ); + assert!( + dirs.contains(&one_spelling(&repo.join(".git"))), + "packed-refs lives in the common dir, and it is a different one, got {dirs:?}" + ); + assert!( + dirs.contains(&one_spelling( + &repo.join(".git").join("refs").join("heads").join("feat") + )), + "`feat/x` needs its namespace listed: the watch does not recurse, got {dirs:?}" + ); + } + + /// A path in the one spelling both halves of that test can agree on. + /// + /// The two halves do not naturally agree. `scm_watch_dirs` passes on what + /// `git rev-parse` answered, and git writes forward slashes and no + /// extended-length prefix even on Windows; the expected side is built from + /// `fs::canonicalize`, which on Windows returns `\\?\C:\…`. Both name the + /// same directory and the Win32 file APIs take either, so the watcher is + /// right to hand git's answer straight to `Host::watch` — it is only the + /// comparison here that has to pick a spelling. + fn one_spelling(p: &Path) -> String { + let slashed = p.to_string_lossy().replace('\\', "/"); + match slashed.strip_prefix("//?/") { + Some(bare) => bare.to_string(), + None => slashed, + } + } + + fn spelled(paths: &[std::path::PathBuf]) -> Vec { + paths.iter().map(|p| one_spelling(p)).collect() + } +} diff --git a/src/terminal/git_diff.rs b/src/terminal/git_diff.rs index ac82dcc4..ddd8cd4d 100644 --- a/src/terminal/git_diff.rs +++ b/src/terminal/git_diff.rs @@ -1,732 +1,6 @@ -use std::path::{Path, PathBuf}; - -use crate::terminal::git_status; -use crate::ui::host_ops::Host; - -pub const MAX_LINES_PER_FILE: usize = 2000; - -pub const MAX_TOTAL_LINES: usize = 20_000; - -pub const MAX_FILES_WITH_HUNKS: usize = 500; - -pub const AUTO_COLLAPSE_LINES: u32 = 400; - -pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000; - -pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100; - -pub const MAX_RENDERED_FILES: usize = 300; - -pub const MAX_UNTRACKED: usize = 500; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Truncation { - PerFile, - Budget, -} - -#[derive(Clone, PartialEq, Eq, Debug, Default)] -pub struct DiffSnapshot { - pub root: PathBuf, - pub branch: String, - pub files: Vec, - pub untracked: Vec, - pub untracked_total: usize, - pub read_failed: bool, -} - -impl DiffSnapshot { - pub fn totals(&self) -> (u32, u32) { - self.files - .iter() - .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) - } - - pub fn untracked_count(&self) -> usize { - self.untracked_total.max(self.untracked.len()) - } - - pub fn stats(&self) -> DiffStats { - let mut added = 0u32; - let mut removed = 0u32; - let mut retained_lines = 0usize; - let mut budget_exhausted = false; - let mut per_file_truncated = false; - for file in &self.files { - added += file.added; - removed += file.removed; - retained_lines += file.hunks.iter().map(|h| h.lines.len()).sum::(); - match file.truncated { - Some(Truncation::Budget) => budget_exhausted = true, - Some(Truncation::PerFile) => per_file_truncated = true, - None => {} - } - } - let untracked_count = self.untracked_count(); - DiffStats { - totals: (added, removed), - retained_lines, - untracked_count, - oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES - || retained_lines > AUTO_COLLAPSE_TOTAL_LINES, - budget_exhausted, - per_file_truncated, - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub struct DiffStats { - pub totals: (u32, u32), - pub retained_lines: usize, - pub untracked_count: usize, - pub oversized: bool, - pub budget_exhausted: bool, - pub per_file_truncated: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum FileStatus { - Added, - Modified, - Deleted, - Renamed, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct FileDiff { - pub path: String, - pub old_path: Option, - pub status: FileStatus, - pub added: u32, - pub removed: u32, - pub binary: bool, - pub truncated: Option, - pub hunks: Vec, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct Hunk { - pub header: String, - pub lines: Vec, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum LineKind { - Context, - Added, - Removed, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct DiffLine { - pub kind: LineKind, - pub old_no: Option, - pub new_no: Option, - pub text: String, -} - -pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?; - let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); - let branch = git_status::branch_name(host, cwd)?; - let mut parser = DiffParser::default(); - let diffed = host.git_lines( - cwd, - &["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"], - &mut |line| parser.push_line(line), - ); - let files = match diffed { - Ok(Some(0)) => parser.finish(), - _ => Vec::new(), - }; - let mut untracked: Vec = Vec::new(); - let mut untracked_total = 0usize; - let listed = host.git_lines( - cwd, - &["ls-files", "--others", "--exclude-standard", "--full-name"], - &mut |line| { - untracked_total += 1; - if untracked.len() < MAX_UNTRACKED { - untracked.push(line.to_string()); - } - }, - ); - if !matches!(listed, Ok(Some(0))) { - untracked.clear(); - untracked_total = 0; - } - Some(DiffSnapshot { - root, - branch, - files, - untracked, - untracked_total, - read_failed: !matches!(diffed, Ok(Some(0))) || !matches!(listed, Ok(Some(0))), - }) -} - -#[cfg(test)] -pub fn parse_unified(out: &str) -> Vec { - let mut parser = DiffParser::default(); - for line in out.lines() { - parser.push_line(line); - } - parser.finish() -} - -#[derive(Default)] -pub struct DiffParser { - files: Vec, - old_no: u32, - new_no: u32, - file_lines: usize, - total_lines: usize, - files_with_hunks: usize, - in_hunk: bool, -} - -impl DiffParser { - pub fn push_line(&mut self, line: &str) { - if let Some(rest) = line.strip_prefix("diff --git ") { - let (old_p, new_p) = parse_git_header_paths(rest); - self.files.push(FileDiff { - path: new_p.clone(), - old_path: (old_p != new_p).then_some(old_p), - status: FileStatus::Modified, - added: 0, - removed: 0, - binary: false, - truncated: None, - hunks: Vec::new(), - }); - self.file_lines = 0; - self.in_hunk = false; - return; - } - let Some(file) = self.files.last_mut() else { - return; - }; - if line.starts_with("new file mode") { - file.status = FileStatus::Added; - return; - } - if line.starts_with("deleted file mode") { - file.status = FileStatus::Deleted; - return; - } - if line.starts_with("rename from ") { - file.status = FileStatus::Renamed; - return; - } - if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { - file.binary = true; - return; - } - if !self.in_hunk - && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) - && !line.starts_with("@@") - { - return; - } - if line.starts_with("@@") { - self.in_hunk = true; - if file.truncated.is_some() { - return; - } - let first_hunk = file.hunks.is_empty(); - if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS) - || self.total_lines >= MAX_TOTAL_LINES - { - file.truncated = Some(Truncation::Budget); - return; - } - if first_hunk { - self.files_with_hunks += 1; - } - let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0)); - self.old_no = o; - self.new_no = n; - file.hunks.push(Hunk { - header: line.to_string(), - lines: Vec::new(), - }); - return; - } - if !self.in_hunk { - return; - } - let (kind, text) = match line.as_bytes().first() { - Some(b'+') => (LineKind::Added, &line[1..]), - Some(b'-') => (LineKind::Removed, &line[1..]), - Some(b' ') => (LineKind::Context, &line[1..]), - _ => return, - }; - match kind { - LineKind::Added => file.added += 1, - LineKind::Removed => file.removed += 1, - LineKind::Context => {} - } - if file.truncated.is_some() { - return; - } - self.file_lines += 1; - if self.file_lines > MAX_LINES_PER_FILE { - file.truncated = Some(Truncation::PerFile); - return; - } - if self.total_lines >= MAX_TOTAL_LINES { - file.truncated = Some(Truncation::Budget); - return; - } - let Some(hunk) = file.hunks.last_mut() else { - return; - }; - let (o, n) = match kind { - LineKind::Added => { - let n = self.new_no; - self.new_no += 1; - (None, Some(n)) - } - LineKind::Removed => { - let o = self.old_no; - self.old_no += 1; - (Some(o), None) - } - LineKind::Context => { - let (o, n) = (self.old_no, self.new_no); - self.old_no += 1; - self.new_no += 1; - (Some(o), Some(n)) - } - }; - hunk.lines.push(DiffLine { - kind, - old_no: o, - new_no: n, - text: text.to_string(), - }); - self.total_lines += 1; - } - - pub fn finish(self) -> Vec { - self.files - } -} - -fn is_hunk_line(line: &str) -> bool { - matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() -} - -fn parse_git_header_paths(rest: &str) -> (String, String) { - if rest.starts_with('"') { - let parts: Vec = parse_quoted_pair(rest); - if parts.len() == 2 { - return (strip_prefix_ab(&parts[0]), strip_prefix_ab(&parts[1])); - } - } - if let Some(idx) = rest.rfind(" b/") { - let old = &rest[..idx]; - let new = &rest[idx + 1..]; - return (strip_prefix_ab(old), strip_prefix_ab(new)); - } - (rest.to_string(), rest.to_string()) -} - -fn parse_quoted_pair(s: &str) -> Vec { - let mut parts = Vec::new(); - let mut cur = String::new(); - let mut in_quote = false; - let mut escaped = false; - for ch in s.chars() { - if escaped { - cur.push(ch); - escaped = false; - continue; - } - match ch { - '\\' if in_quote => escaped = true, - '"' => { - if in_quote { - parts.push(std::mem::take(&mut cur)); - } - in_quote = !in_quote; - } - _ if in_quote => cur.push(ch), - _ => {} - } - } - parts -} - -fn strip_prefix_ab(p: &str) -> String { - p.strip_prefix("a/") - .or_else(|| p.strip_prefix("b/")) - .unwrap_or(p) - .to_string() -} - -fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> { - let rest = line.strip_prefix("@@ -")?; - let (old_part, rest) = rest.split_once(" +")?; - let (new_part, _) = rest.split_once(" @@")?; - let old = old_part.split(',').next()?.parse().ok()?; - let new = new_part.split(',').next()?.parse().ok()?; - Some((old, new)) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ -diff --git a/src/main.rs b/src/main.rs -index 1111111..2222222 100644 ---- a/src/main.rs -+++ b/src/main.rs -@@ -10,4 +10,5 @@ fn main() { - let a = 1; --let b = old(); -+let b = new(); -+let c = 3; - done(); -diff --git a/docs/new.md b/docs/new.md -new file mode 100644 -index 0000000..3333333 ---- /dev/null -+++ b/docs/new.md -@@ -0,0 +1,2 @@ -+hello -+world -diff --git a/gone.txt b/gone.txt -deleted file mode 100644 -index 4444444..0000000 ---- a/gone.txt -+++ /dev/null -@@ -1,1 +0,0 @@ --bye -diff --git a/img.png b/img.png -index 5555555..6666666 100644 -Binary files a/img.png and b/img.png differ -"; - - #[test] - fn parses_the_four_file_shapes() { - let files = parse_unified(SAMPLE); - assert_eq!(files.len(), 4); - - let m = &files[0]; - assert_eq!(m.path, "src/main.rs"); - assert_eq!(m.status, FileStatus::Modified); - assert_eq!((m.added, m.removed), (2, 1)); - assert_eq!(m.hunks.len(), 1); - assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {"); - let lines = &m.hunks[0].lines; - assert_eq!(lines.len(), 5); - assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10))); - assert_eq!(lines[1].kind, LineKind::Removed); - assert_eq!(lines[1].old_no, Some(11)); - assert_eq!(lines[1].new_no, None); - assert_eq!(lines[2].kind, LineKind::Added); - assert_eq!(lines[2].new_no, Some(11)); - assert_eq!(lines[3].new_no, Some(12)); - assert_eq!(lines[3].text, "let c = 3;"); - assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13))); - - let a = &files[1]; - assert_eq!(a.status, FileStatus::Added); - assert_eq!((a.added, a.removed), (2, 0)); - - let d = &files[2]; - assert_eq!(d.status, FileStatus::Deleted); - assert_eq!((d.added, d.removed), (0, 1)); - - let b = &files[3]; - assert!(b.binary); - assert!(b.hunks.is_empty()); - } - - #[test] - fn parses_renames() { - let out = "\ -diff --git a/old/name.rs b/new/name.rs -similarity index 100% -rename from old/name.rs -rename to new/name.rs -"; - let files = parse_unified(out); - assert_eq!(files.len(), 1); - assert_eq!(files[0].status, FileStatus::Renamed); - assert_eq!(files[0].path, "new/name.rs"); - assert_eq!(files[0].old_path.as_deref(), Some("old/name.rs")); - assert_eq!((files[0].added, files[0].removed), (0, 0)); - } - - #[test] - fn parses_quoted_paths() { - let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n"; - let files = parse_unified(out); - assert_eq!(files[0].path, "has space.txt"); - assert_eq!(files[0].old_path, None); - } - - #[test] - fn triple_dash_content_line_is_kept() { - let out = "\ -diff --git a/x.md b/x.md -index 1111111..2222222 100644 ---- a/x.md -+++ b/x.md -@@ -1,2 +1,1 @@ - keep ----- a heading rule -"; - let files = parse_unified(out); - let lines = &files[0].hunks[0].lines; - assert_eq!(lines.len(), 2); - assert_eq!(lines[1].kind, LineKind::Removed); - assert_eq!(lines[1].text, "--- a heading rule"); - } - - #[test] - fn caps_lines_per_file_but_keeps_counting() { - let mut out = String::from( - "diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n", - ); - for i in 0..3000 { - out.push_str(&format!("+line {i}\n")); - } - let files = parse_unified(&out); - assert_eq!(files[0].truncated, Some(Truncation::PerFile)); - assert_eq!(files[0].added, 3000); - let kept: usize = files[0].hunks.iter().map(|h| h.lines.len()).sum(); - assert_eq!(kept, MAX_LINES_PER_FILE); - } - - #[test] - fn skips_no_newline_marker() { - let out = "\ -diff --git a/x b/x -index 1..2 100644 ---- a/x -+++ b/x -@@ -1,1 +1,1 @@ --old -\\ No newline at end of file -+new -\\ No newline at end of file -"; - let files = parse_unified(out); - assert_eq!(files[0].hunks[0].lines.len(), 2); - assert_eq!((files[0].added, files[0].removed), (1, 1)); - } - - #[test] - fn snapshot_totals() { - let snap = DiffSnapshot { - files: parse_unified(SAMPLE), - ..Default::default() - }; - assert_eq!(snap.totals(), (4, 2)); - } - - fn many_files(files: usize, lines_each: usize) -> String { - let mut out = String::new(); - for f in 0..files { - out.push_str(&format!( - "diff --git a/f{f}.rs b/f{f}.rs\nindex 1..2 100644\n--- a/f{f}.rs\n+++ b/f{f}.rs\n@@ -0,0 +1,{lines_each} @@\n" - )); - for i in 0..lines_each { - out.push_str(&format!("+file {f} line {i}\n")); - } - } - out - } - - #[test] - fn repo_wide_budget_caps_retained_lines() { - let files = parse_unified(&many_files(300, 300)); - assert_eq!(files.len(), 300, "every file keeps its header row"); - let retained: usize = files - .iter() - .flat_map(|f| &f.hunks) - .map(|h| h.lines.len()) - .sum(); - assert!( - retained <= MAX_TOTAL_LINES, - "retained {retained} lines, budget is {MAX_TOTAL_LINES}" - ); - assert!( - files - .iter() - .any(|f| f.truncated == Some(Truncation::Budget)) - ); - } - - #[test] - fn repo_wide_budget_keeps_totals_exact() { - let snap = DiffSnapshot { - files: parse_unified(&many_files(300, 300)), - ..Default::default() - }; - assert_eq!(snap.totals(), (90_000, 0)); - assert!(snap.stats().budget_exhausted); - } - - #[test] - fn repo_wide_budget_caps_files_with_hunks() { - let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1)); - assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50); - let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count(); - assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS); - assert_eq!( - files.iter().map(|f| f.added).sum::(), - (MAX_FILES_WITH_HUNKS + 50) as u32 - ); - assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget)); - } - - #[test] - fn small_diff_is_not_truncated() { - let snap = DiffSnapshot { - files: parse_unified(SAMPLE), - ..Default::default() - }; - assert!(snap.files.iter().all(|f| f.truncated.is_none())); - assert!(!snap.stats().oversized); - assert!(!snap.stats().budget_exhausted); - } - - #[test] - fn oversized_trips_on_files_or_lines() { - let by_files = DiffSnapshot { - files: parse_unified(&many_files(AUTO_COLLAPSE_TOTAL_FILES + 1, 1)), - ..Default::default() - }; - assert!(by_files.stats().oversized); - - let per_file = MAX_LINES_PER_FILE / 2; - let by_lines = DiffSnapshot { - files: parse_unified(&many_files( - AUTO_COLLAPSE_TOTAL_LINES / per_file + 1, - per_file, - )), - ..Default::default() - }; - assert!(by_lines.files.len() <= AUTO_COLLAPSE_TOTAL_FILES); - assert!(by_lines.stats().retained_lines > AUTO_COLLAPSE_TOTAL_LINES); - assert!(by_lines.stats().oversized); - } - - #[test] - fn truncated_file_counts_dash_prefixed_content() { - let mut out = many_files(MAX_FILES_WITH_HUNKS, 1); - out.push_str( - "diff --git a/late.md b/late.md\nindex 1..2 100644\n--- a/late.md\n+++ b/late.md\n@@ -1,2 +1,1 @@\n keep\n--- a heading rule\n", - ); - let files = parse_unified(&out); - let late = files.last().unwrap(); - assert_eq!(late.path, "late.md"); - assert_eq!(late.truncated, Some(Truncation::Budget)); - assert!(late.hunks.is_empty(), "no body kept past the file cap"); - assert_eq!((late.added, late.removed), (0, 1), "but the line counts"); - } - - #[test] - fn untracked_is_capped_but_counted() { - let mut untracked: Vec = Vec::new(); - let mut untracked_total = 0usize; - for i in 0..(MAX_UNTRACKED * 3) { - untracked_total += 1; - if untracked.len() < MAX_UNTRACKED { - untracked.push(format!("node_modules/p{i}/index.js")); - } - } - let snap = DiffSnapshot { - untracked, - untracked_total, - ..Default::default() - }; - assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded"); - assert_eq!( - snap.untracked_count(), - MAX_UNTRACKED * 3, - "the count is not" - ); - } - - #[test] - #[ignore = "measurement, not an assertion"] - fn bench_stream_vs_buffer() { - use std::time::Instant; - use tty7_core::core::git::{LineSplitter, git_output, git_stream}; - - let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let args = ["log", "-p", "-n", "400", "--no-color"]; - - let t = Instant::now(); - let Ok(out) = git_output(here, &args) else { - println!("no git here; skipping"); - return; - }; - let read = t.elapsed(); - let resident = out.stdout.len(); - let t = Instant::now(); - let buffered_files = parse_unified(&String::from_utf8_lossy(&out.stdout)); - let buffered_parse = t.elapsed(); - println!( - "buffered: {resident} bytes resident, read {read:?}, parse {buffered_parse:?}, \ - {} files", - buffered_files.len() - ); - drop(out); - - let t = Instant::now(); - let mut parser = DiffParser::default(); - let mut split = LineSplitter::default(); - let mut peak = 0usize; - git_stream(here, &args, |chunk| { - peak = peak.max(chunk.len()); - split.push(chunk, |line| parser.push_line(line)); - true - }) - .unwrap(); - split.finish(|line| parser.push_line(line)); - let streamed = t.elapsed(); - let streamed_files = parser.finish(); - println!( - "streamed: peak transient chunk {peak} bytes (vs {resident} resident), \ - read+parse {streamed:?}, {} files", - streamed_files.len() - ); - assert_eq!(buffered_files.len(), streamed_files.len()); - } - - #[test] - #[ignore = "measurement, not an assertion"] - fn bench_parse_budget() { - use std::time::Instant; - let out = many_files(300, 300); - println!("input: {} bytes, 300 files × 300 lines", out.len()); - let t = Instant::now(); - let files = parse_unified(&out); - let elapsed = t.elapsed(); - let retained: usize = files - .iter() - .flat_map(|f| &f.hunks) - .map(|h| h.lines.len()) - .sum(); - let bytes: usize = files - .iter() - .flat_map(|f| &f.hunks) - .flat_map(|h| &h.lines) - .map(|l| l.text.capacity() + std::mem::size_of::()) - .sum(); - println!( - "parse {elapsed:?} → {retained} retained lines, ~{} KiB of DiffLine text \ - (unbudgeted would be 90000 lines / ~{} KiB)", - bytes / 1024, - 90_000 * (24 + std::mem::size_of::()) / 1024, - ); - } -} +//! The diff model moved into `tty7-core` (`core::git::diff`) so the headless +//! server and the GUI parse a patch with the same code. Nothing here has a gpui +//! dependency, and the SCM panel needs the same types the daemon does. +//! +//! This shim keeps every `crate::terminal::git_diff::…` path in the GUI working. +pub use tty7_core::core::git::diff::*; diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index 7070d169..ebb1f08c 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe}; +pub use crate::core::git::{GitStatus, RepoSnapshot, probe}; use crate::ui::host_ops::{ByHost, HostId, InFlight}; #[derive(Default)] @@ -31,6 +31,25 @@ impl GitStatusCache { })) } + /// The working tree `cwd` is in, if this cache has already found out. + /// + /// Distinct from [`GitStatusCache::known_repo_for`], which answers with the + /// *home* — the main working tree a linked one belongs to, which is what + /// a "which project is this" question wants. This answers with the root, + /// which is the key everything git-shaped is stored under. + pub fn repo_root_for(&self, host: HostId, cwd: &Path) -> Option<&Path> { + self.roots.get(host, cwd)?.as_deref() + } + + /// Forget a machine we have stopped talking to, so a reconnect starts from + /// nothing rather than from whatever it looked like on the way down. + pub fn clear_host(&mut self, host: HostId) { + self.roots.clear_host(host); + self.homes.clear_host(host); + self.status.clear_host(host); + self.last_probe.clear_host(host); + } + pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool { let key = (host, cwd.to_path_buf()); if self.probes.begin(key.clone()) { @@ -319,6 +338,49 @@ mod tests { assert_eq!(cache.status_for(L, main).unwrap().branch, "main"); assert_eq!(cache.status_for(L, wt).unwrap().branch, "feat/x"); } + #[test] + fn a_linked_worktrees_root_is_not_its_home() { + // `known_repo_for` groups a worktree with the repository it belongs + // to; `repo_root_for` answers with the working tree itself, which is + // the key every git-shaped cache is stored under. + let mut cache = GitStatusCache::default(); + let wt = Path::new("/repo/.wt/feat"); + cache.finish_probe(L, wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x"))); + + assert_eq!( + cache.repo_root_for(L, wt), + Some(Path::new("/repo/.wt/feat")) + ); + assert_eq!( + cache.known_repo_for(L, wt), + Some(Some(PathBuf::from("/repo"))) + ); + + let plain = Path::new("/tmp/notes"); + cache.finish_probe(L, plain, None); + assert_eq!(cache.repo_root_for(L, plain), None, "not a repository"); + assert_eq!(cache.repo_root_for(L, Path::new("/never")), None); + } + + #[test] + fn clearing_a_host_leaves_the_others_alone() { + let mut cache = GitStatusCache::default(); + let gone = HostId::from_connection_key("ssh-direct:me@box:22"); + let cwd = Path::new("/src/app"); + cache.finish_probe(L, cwd, Some(snap("/src/app", "main", Some((1, 2))))); + cache.finish_probe(gone, cwd, Some(snap("/src/app", "feat/x", Some((3, 4))))); + + cache.clear_host(gone); + + assert_eq!(cache.status_for(gone, cwd), None); + assert_eq!(cache.known_repo_for(gone, cwd), None); + assert!( + cache.begin_probe_throttled(gone, cwd, Duration::from_secs(60)), + "a reconnect must be free to ask again straight away" + ); + assert_eq!(cache.status_for(L, cwd).unwrap().branch, "main"); + } + #[test] fn throttled_probes_decline_instead_of_queueing() { let mut cache = GitStatusCache::default(); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 1da5da26..cdc79d9d 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -5,6 +5,7 @@ pub mod element; pub mod fps; mod fuzzy; mod generator; +pub(crate) mod git_data; pub(crate) mod git_diff; pub(crate) mod git_status; mod highlight; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 6d18d1c2..96fbf192 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2830,6 +2830,9 @@ impl TerminalView { }) .flatten(); if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished { + if cmd_finished || turn_finished { + self.mark_repo_changed(cwd_now.as_deref(), cx); + } self.refresh_git_status(cwd_now, GitRefresh::Edge, cx); } else if tool_activity { self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx); @@ -2838,6 +2841,33 @@ impl TerminalView { self.follow_history_scope(cx); } + /// Tell the source control cache that a command just ran here. + /// + /// The `.git` watch catches anything that writes the repository, and the + /// file tree catches edits in the directories it is showing. What is left + /// is the common case neither sees: a command that edits a file somewhere + /// the tree is not looking. A command boundary is the cheapest honest + /// signal that that may have happened. + /// + /// Only the epoch moves. Scheduling the debounced re-read needs the app + /// entity, which a pane does not hold — but `refresh_git_status` below + /// writes `GitStatusCache`, the app observes that global, and the panel's + /// next render finds the repository stale and asks. One notify, not two. + fn mark_repo_changed(&self, cwd: Option<&std::path::Path>, cx: &mut Context) { + use crate::terminal::git_data::ScmData; + use crate::terminal::git_status::GitStatusCache; + + let Some(cwd) = cwd else { return }; + let Some(root) = cx + .try_global::() + .and_then(|cache| cache.repo_root_for(self.host_id, cwd)) + .map(std::path::Path::to_path_buf) + else { + return; + }; + cx.default_global::().bump(self.host_id, &root); + } + fn desired_history_scope(&self) -> super::history::Scope { if let Some(ctx) = self.remote_context() { return super::history::Scope::remote(&ctx.target); diff --git a/src/ui/app.rs b/src/ui/app.rs index 9eb15e7c..70d8aa89 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -34,7 +34,10 @@ use crate::ui::palette::{ }; use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot}; use crate::ui::presets::Fill; -use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor}; +use crate::ui::scm::ScmIntent; +use crate::ui::settings::{ + Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action, +}; use crate::ui::theme::{apply_theme, set_menus}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -115,6 +118,15 @@ pub(crate) const TILE_GLYPH: f32 = 13.; pub(crate) const TILE_SIZE_SM: f32 = 24.; pub(crate) const TILE_GLYPH_SM: f32 = TILE_GLYPH; +/// The tile that lives *inside* a list row rather than beside one, for the +/// buttons a row reveals on hover. +/// +/// A box below [`TILE_SIZE_SM`] because of width: three `TILE_SIZE_SM` squares +/// would eat 72 of the 236px a file name has to live in, where three of these +/// eat 54. +pub(crate) const TILE_SIZE_XS: f32 = 18.; +pub(crate) const TILE_GLYPH_XS: f32 = 11.; + pub(crate) const TILE_GLYPH_LINE: f32 = 16.; pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.; @@ -430,6 +442,7 @@ pub struct Tty7App { pub(crate) loopback_panel: LoopbackForwardPanelState, pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState, pub(crate) right_panel: crate::ui::right_panel::RightPanelState, + pub(crate) scm: crate::ui::scm::ScmPanelState, pub(crate) diff_probes_inflight: std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>, pub(crate) diff_probes_restale: @@ -827,6 +840,7 @@ impl Tty7App { let right_panel_width = cx.global::().right_panel_width; let right_panel_visible = cx.global::().right_panel_visible; let right_panel_tab = cx.global::().right_panel_tab; + let scm_graph_expanded = cx.global::().scm_graph_expanded; let sidebar_collapsed = cx.global::().sidebar_collapsed; let config_watch = cx.observe_global_in::(window, |this, window, cx| { this.reload_from_config(window, cx) @@ -974,6 +988,13 @@ impl Tty7App { }, sftp_panel, right_panel: Default::default(), + scm: crate::ui::scm::ScmPanelState { + graph: crate::ui::scm::GraphState { + expanded: scm_graph_expanded, + ..Default::default() + }, + ..Default::default() + }, diff_probes_inflight: Default::default(), diff_probes_restale: Default::default(), file_tree, @@ -4078,6 +4099,20 @@ impl Tty7App { OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx), SendSelectionToAgent => self.send_selection_to_agent(window, cx), SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx), + ScmCommit => self.run_scm_action(ScmIntent::Commit, window, cx), + ScmStageAll => self.run_scm_action(ScmIntent::StageAll, window, cx), + ScmUnstageAll => self.run_scm_action(ScmIntent::UnstageAll, window, cx), + ScmDiscardAll => self.run_scm_action(ScmIntent::DiscardAll, window, cx), + ScmPush => self.run_scm_action(ScmIntent::Push, window, cx), + ScmPull => self.run_scm_action(ScmIntent::Pull, window, cx), + ScmFetch => self.run_scm_action(ScmIntent::Fetch, window, cx), + ScmSync => self.run_scm_action(ScmIntent::Sync, window, cx), + ScmCreateBranch => self.run_scm_action(ScmIntent::CreateBranch, window, cx), + OpenBranchPicker => self.run_scm_action(ScmIntent::CheckoutBranch, window, cx), + // The branch picker fills this in once it can list refs; until + // then the palette never emits it. + CheckoutBranch(_) => {} + ToggleDiffViewMode => self.toggle_diff_view_mode(cx), OpenThemePicker | OpenSshConnectInput => {} ActivateTab(i) => self.activate(i, window, cx), } @@ -5949,6 +5984,7 @@ impl Render for Tty7App { window.set_rem_size(px(cx.global::().ui_font_size)); self.claim_pending_tab(window, cx); self.touch_active_tab(); + self.scm_sync_watchers(window, cx); if cx.has_active_drag() { crate::ui::reorder::clear_pending(&self.reorder); crate::ui::pane_drag::clear_landing(&self.pane_drag); @@ -6333,11 +6369,53 @@ impl Render for Tty7App { this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx) })) .on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| { - this.set_right_panel_tab(crate::core::config::RightPanelTab::Changes, cx) + this.set_right_panel_tab(crate::core::config::RightPanelTab::Scm, cx) })) .on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| { this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx) })) + .on_action( + cx.listener(|this, _: &ScmToggleGraph, _window, cx| this.scm_toggle_graph(cx)), + ) + .on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| { + this.toggle_diff_view_mode(cx) + })) + .on_action(cx.listener(|this, _: &ScmCommit, window, cx| { + this.run_scm_action(ScmIntent::Commit, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmCommitAmend, window, cx| { + this.run_scm_action(ScmIntent::CommitAmend, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmStageAll, window, cx| { + this.run_scm_action(ScmIntent::StageAll, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmUnstageAll, window, cx| { + this.run_scm_action(ScmIntent::UnstageAll, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmDiscardAll, window, cx| { + this.run_scm_action(ScmIntent::DiscardAll, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmRefresh, window, cx| { + this.run_scm_action(ScmIntent::Refresh, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmSync, window, cx| { + this.run_scm_action(ScmIntent::Sync, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmPush, window, cx| { + this.run_scm_action(ScmIntent::Push, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmPull, window, cx| { + this.run_scm_action(ScmIntent::Pull, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmFetch, window, cx| { + this.run_scm_action(ScmIntent::Fetch, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmCheckoutBranch, window, cx| { + this.run_scm_action(ScmIntent::CheckoutBranch, window, cx) + })) + .on_action(cx.listener(|this, _: &ScmCreateBranch, window, cx| { + this.run_scm_action(ScmIntent::CreateBranch, window, cx) + })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { this.toggle_settings(window, cx) })) diff --git a/src/ui/assets.rs b/src/ui/assets.rs index ff8d1749..0bac3723 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -26,6 +26,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { let bytes: &'static [u8] = match path { "icons/terminal.svg" => include_bytes!("../../assets/icons/terminal.svg"), "icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"), + // Deliberately not `refresh.svg`: the panel header already carries a + // refresh tile, and the same glyph meaning two different things one row + // apart reads as a bug. + "icons/git-sync.svg" => include_bytes!("../../assets/icons/git-sync.svg"), + "icons/git-commit.svg" => include_bytes!("../../assets/icons/git-commit.svg"), "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), @@ -93,6 +98,22 @@ mod tests { } } + #[test] + fn every_git_icon_resolves() { + // An SVG on disk that nobody added to the match above silently renders + // as nothing, which is exactly the kind of miss no one notices. + for path in [ + "icons/git-branch.svg", + "icons/git-sync.svg", + "icons/git-commit.svg", + ] { + assert!( + Assets.load(path).unwrap().is_some(), + "{path} is not registered in `agent_icon`" + ); + } + } + #[test] fn stock_prefix_works_for_unoverridden_glyphs() { assert_eq!( diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 15d741af..1e3e3c0a 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -617,6 +617,8 @@ impl Tty7App { f.saving = Some(seq); let text = f.input.read(cx).text().to_string(); let target = f.path.clone(); + let host_id = host.id(); + let saved_in = target.parent().map(std::path::Path::to_path_buf); HostOps::run_in( host, window, @@ -633,6 +635,7 @@ impl Tty7App { f.edit_seq, std::mem::take(&mut f.save_pending), ); + let wrote = result.is_ok(); match result { Ok(mtime) => { f.disk_mtime = mtime; @@ -654,6 +657,12 @@ impl Tty7App { f.dirty = false; f.conflict = false; } + // A save is a working-tree edit the `.git` watch cannot see, + // and the file tree only sees it while it happens to be showing + // that directory. + if wrote && let Some(dir) = &saved_in { + app.scm_invalidate_cwd(host_id, dir, cx); + } if landing.requeue { app.editor_save_file(id, false, window, cx); cx.notify(); diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 4611ea4a..46eeb4ea 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3,20 +3,31 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui::{ - AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, Pixels, Window, div, - prelude::*, px, + AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, Pixels, SharedString, + Window, div, prelude::*, px, }; use gpui_component::button::Button; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use crate::core::config::{Config, DiffViewMode}; +use crate::core::git::status::DecoStatus; use crate::terminal::git_diff::{ - self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffStats, FileDiff, FileStatus, LineKind, - MAX_RENDERED_FILES, Truncation, + self, AUTO_COLLAPSE_LINES, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff, + FileStatus, LineKind, MAX_RENDERED_FILES, Truncation, }; + +/// How much of an untracked file the preview will read. Past this the card +/// says the read failed rather than showing a silently cut-off file — and the +/// line budget below cuts rendering long before this does anyway. +const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024; use crate::ui::app::Tty7App; +use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; 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 { Loading, @@ -27,12 +38,50 @@ pub(crate) enum DiffLoad { pub(crate) struct DiffOverlayState { pub(crate) host_id: crate::ui::host_ops::HostId, pub(crate) cwd: PathBuf, + /// Which patch this overlay is showing. Part of its identity, not a + /// setting: two sources over one directory are two different overlays. + pub(crate) source: DiffSource, pub(crate) focus_handle: FocusHandle, pub(crate) load: DiffLoad, pub(crate) loading: bool, pub(crate) expanded: HashMap, pub(crate) focus: Option, + /// A synthesized all-added card for a focused *untracked* file, keyed by + /// path; `None` in the value means the read failed. git has no patch for + /// an untracked file, so focusing one reads its bytes instead — lazily, + /// only for the file on screen, never for the whole list. Cleared when a + /// fresh snapshot lands, so an edit to the file shows up on the same + /// cadence a tracked file's does. + pub(crate) preview: Option<(String, Option>)>, + pub(crate) preview_loading: Option, pub(crate) scroll: gpui::ScrollHandle, + /// The [`ScmData`](crate::terminal::git_data::ScmData) epoch this patch was + /// read at, for the two sources that can go stale. + /// + /// Recorded when a probe *starts*, so a `git add` that lands while one is + /// running is not mistaken for a change the result already reflects. + /// `None` until the first snapshot arrives: the epoch is keyed by the + /// repository root, and only a snapshot knows where that is. + pub(crate) epoch: Option, +} + +/// One hunk, already turned into whichever kind of row the current view draws. +enum HunkRows { + Split(Vec), + Unified(Vec), +} + +impl HunkRows { + fn len(&self) -> usize { + match self { + HunkRows::Split(rows) => rows.len(), + HunkRows::Unified(rows) => rows.len(), + } + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } } /// Paints the full-window diff surface without inheriting workspace opacity. @@ -66,6 +115,20 @@ impl Tty7App { focus: Option, window: &mut Window, cx: &mut Context, + ) { + // The sidebar's `+N −M` is `diff --numstat HEAD`, so opening it has + // to show the same span. The panel names its own source per group. + self.open_diff_overlay(host, cwd, DiffSource::Head, focus, window, cx) + } + + pub(crate) fn open_diff_overlay( + &mut self, + host: crate::ui::host_ops::HostId, + cwd: PathBuf, + source: DiffSource, + focus: Option, + window: &mut Window, + cx: &mut Context, ) { let active = self.active; let was_front = self.tabs.get(active).is_some_and(|t| { @@ -74,11 +137,14 @@ impl Tty7App { if let Some(tab) = self.tabs.get_mut(active) { tab.overlay_top = crate::ui::app::OverlayTop::Diff; } + // The source belongs in this filter: an open worktree overlay reused + // for a staged file would only move the focus, and go on showing the + // unstaged patch under the staged file's name. match self .tabs .get_mut(active) .and_then(|t| t.diff_overlay.as_mut()) - .filter(|o| o.cwd == cwd && o.host_id == host) + .filter(|o| o.cwd == cwd && o.host_id == host && o.source == source) { Some(o) if o.focus == focus && was_front => { self.close_diff_overlay(window, cx); @@ -93,12 +159,6 @@ impl Tty7App { } None => {} } - let seed = match (&self.right_panel.diff_cwd, &self.right_panel.diff) { - (Some(panel_key), Some(Some(snap))) if *panel_key == (host, cwd.clone()) => { - DiffLoad::Ready(Arc::clone(snap)) - } - _ => DiffLoad::Loading, - }; self.remember_active_pane(window, cx); let Some(tab) = self.tabs.get_mut(active) else { return; @@ -107,25 +167,38 @@ impl Tty7App { tab.diff_overlay = Some(DiffOverlayState { host_id: host, cwd, + source, focus_handle: focus_handle.clone(), - load: seed, + // Every open starts at Loading until its own probe lands. The old + // panel-snapshot seeding died with the panel that held a snapshot + // per source; re-seeding would need the caller to carry one. + load: DiffLoad::Loading, loading: false, expanded: HashMap::new(), focus, + preview: None, + preview_loading: None, scroll: gpui::ScrollHandle::new(), + epoch: None, }); window.focus(&focus_handle, cx); self.spawn_diff_probe(cx); cx.notify(); } + /// Which file the open overlay is focused on — for the row that asked, + /// which means the *source* has to match too: a file staged and edited + /// again sits in two panel groups, and only the row whose patch is + /// actually on screen may draw itself selected. pub(crate) fn diff_overlay_focus( &self, host: crate::ui::host_ops::HostId, cwd: &std::path::Path, + source: &crate::terminal::git_diff::DiffSource, ) -> Option<&str> { let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?; - (overlay.cwd == cwd && overlay.host_id == host).then_some(overlay.focus.as_deref())? + (overlay.cwd == cwd && overlay.host_id == host && overlay.source == *source) + .then_some(overlay.focus.as_deref())? } pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context) { @@ -142,6 +215,24 @@ impl Tty7App { fn spawn_diff_probe(&mut self, cx: &mut Context) { let active = self.active; + let Some(overlay) = self.tabs.get(active).and_then(|t| t.diff_overlay.as_ref()) else { + return; + }; + if overlay.loading { + return; + } + let cwd = overlay.cwd.clone(); + let source = overlay.source.clone(); + let id = overlay.host_id; + // Read before the probe is dispatched, not after it lands: anything + // bumped in between belongs to the next read, not this one. + let epoch = match &overlay.load { + DiffLoad::Ready(snap) => Some(scm_epoch(cx, id, &snap.root)), + _ => None, + }; + let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, id) else { + return; + }; let Some(overlay) = self .tabs .get_mut(active) @@ -149,40 +240,42 @@ impl Tty7App { else { return; }; - if overlay.loading { - return; - } - let cwd = overlay.cwd.clone(); - let id = overlay.host_id; - let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, id) else { - return; - }; overlay.loading = true; - self.spawn_shared_diff_probe(host, cwd, cx); + overlay.epoch = epoch; + self.spawn_diff_probe_for(host, cwd, source, cx); } - pub(crate) fn spawn_shared_diff_probe( + pub(crate) fn spawn_diff_probe_for( &mut self, host: crate::ui::host_ops::SharedHost, cwd: PathBuf, + source: DiffSource, cx: &mut Context, ) { - let key = (host.id(), cwd.clone()); + let key = probe_key(host.id(), &cwd, &source); if !self.diff_probes_inflight.insert(key.clone()) { self.diff_probes_restale.insert(key); return; } let host_for_retry = host.clone(); let probe_cwd = cwd.clone(); + let probe_source = source.clone(); crate::ui::host_ops::HostOps::run( host, cx, - move |h| git_diff::probe(h, &probe_cwd), + move |h| { + let req = git_diff::DiffRequest { + source: probe_source, + ..Default::default() + }; + git_diff::probe_diff(h, &probe_cwd, &req) + }, move |app, result, cx| { + let id = key.0; app.diff_probes_inflight.remove(&key); - app.install_diff_snapshot(key.0, &cwd, result.map(Arc::new), cx); - if app.diff_probes_restale.remove(&(key.0, cwd.clone())) { - app.spawn_shared_diff_probe(host_for_retry, cwd, cx); + app.install_diff_snapshot(id, &cwd, &source, result.map(Arc::new), cx); + if app.diff_probes_restale.remove(&key) { + app.spawn_diff_probe_for(host_for_retry, cwd, source, cx); } }, ); @@ -192,6 +285,7 @@ impl Tty7App { &mut self, host: crate::ui::host_ops::HostId, cwd: &Path, + source: &DiffSource, snap: Option>, cx: &mut Context, ) { @@ -214,28 +308,26 @@ impl Tty7App { } else { false }; + // Only wanted by an overlay whose first probe could not know the root, + // and so could not read its own epoch before dispatching. + let landing_epoch = snap.as_ref().map(|s| scm_epoch(cx, host, &s.root)); for tab in self.tabs.iter_mut() { let Some(overlay) = tab .diff_overlay .as_mut() - .filter(|o| o.cwd == cwd && o.host_id == host) + .filter(|o| o.cwd == cwd && o.host_id == host && o.source == *source) else { continue; }; overlay.loading = false; + overlay.epoch = overlay.epoch.or(landing_epoch); overlay.load = match &snap { Some(snap) => DiffLoad::Ready(Arc::clone(snap)), None => DiffLoad::NotARepo, }; - landed = true; - } - let key = (host, cwd.to_path_buf()); - if self.right_panel.diff_pending.as_ref() == Some(&key) { - self.right_panel.diff_pending = None; - landed = true; - } - if self.right_panel.diff_cwd.as_ref() == Some(&key) { - self.right_panel.diff = Some(snap); + // A new snapshot restarts any untracked preview: the file may + // have changed with the tree, and the re-read costs one file. + overlay.preview = None; landed = true; } if landed { @@ -257,22 +349,44 @@ impl Tty7App { let DiffLoad::Ready(snap) = &overlay.load else { return; }; - let Some(status) = cx - .try_global::() - .and_then(|cache| cache.status_for(overlay.host_id, &overlay.cwd)) - else { - return; + let stale = match overlay.source { + // A commit and a range are fixed patches. Nothing can make either + // of them out of date, so nothing should reprobe them. + DiffSource::Commit { .. } | DiffSource::Range { .. } => return, + // The cached counts come from `git diff --numstat HEAD`, so only a + // HEAD snapshot is comparable to them. + DiffSource::Head => { + let Some(status) = cx + .try_global::() + .and_then(|cache| cache.status_for(overlay.host_id, &overlay.cwd)) + else { + return; + }; + status.branch != snap.branch || (status.added, status.removed) != snap.totals() + } + // Those same counts would differ from a staged or unstaged patch + // the moment anything is staged, and the overlay would reprobe + // forever. The epoch answers the question that was actually being + // asked — "did anything happen to this repository" — without + // knowing what either side is counting. + DiffSource::Worktree | DiffSource::Staged => { + let Some(seen) = overlay.epoch else { + return; + }; + scm_epoch(cx, overlay.host_id, &snap.root) != seen + } }; - if status.branch != snap.branch || (status.added, status.removed) != snap.totals() { + if stale { self.spawn_diff_probe(cx); } } pub(crate) fn render_diff_overlay( - &self, + &mut self, window: &mut Window, cx: &mut Context, ) -> Option { + self.spawn_untracked_preview_if_needed(cx); let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?; let content = match &overlay.load { @@ -284,6 +398,20 @@ impl Tty7App { DiffLoad::Ready(snap) if empty_snapshot(snap) => { self.diff_message(t(L10nKey::DiffWorkingTreeClean), cx) } + // A focused *untracked* file has no patch in the snapshot; its + // card is synthesized from the file's own bytes — see `preview`. + DiffLoad::Ready(snap) if untracked_focus(snap, overlay.focus.as_deref()).is_some() => { + let path = untracked_focus(snap, overlay.focus.as_deref()).unwrap(); + match &overlay.preview { + Some((held, Some(file))) if held == path => { + self.diff_preview_card(file.as_ref(), &overlay.scroll, cx) + } + Some((held, None)) if held == path => { + self.diff_message(t(L10nKey::DiffReadFailed), cx) + } + _ => self.diff_message(t(L10nKey::DiffReading), cx), + } + } DiffLoad::Ready(snap) => self.diff_file_list( snap, &overlay.expanded, @@ -343,6 +471,8 @@ impl Tty7App { } else { crate::ui::app::TITLE_BAR_LEAD }; + let mono = SharedString::from(self.font_family.clone()); + let subject = source_subject(&overlay.source, branch); let row = crate::ui::app::title_bar_drag( h_flex().id("diff-overlay-header"), "diff-overlay-header", @@ -359,17 +489,56 @@ impl Tty7App { .border_color(cx.theme().border) .child( gpui::svg() - .path("icons/git-branch.svg") + .path(subject.icon) .flex_shrink_0() .size(px(13.)) .text_color(cx.theme().muted_foreground), ) - .child( + .child(if subject.is_rev { + // A revision is an identifier, not a name: it belongs in the + // same monospace the patch below it is set in. + div() + .flex_shrink_0() + .text_size(px(13.)) + .font_family(self.font_family.clone()) + .child(subject.text) + .into_any_element() + } else { div() .text_sm() .font_weight(FontWeight::MEDIUM) - .child(branch), - ) + .child(subject.text) + .into_any_element() + }) + .when_some(subject.chip, |bar, text| { + bar.child(info_chip( + text, + cx.theme().accent.opacity(0.16), + cx.theme().foreground, + &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( @@ -449,7 +618,26 @@ 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); + self.segmented_on( + sf, + "diff-overlay-view", + &[t(L10nKey::DiffViewSplit), t(L10nKey::DiffViewUnified)], + selected, + cx, + |this, index, _window, cx| { + let mode = if index == 0 { + DiffViewMode::Split + } else { + DiffViewMode::Unified + }; + this.update_config(cx, |cfg| cfg.diff_view = mode); + }, + ) + })) .child( div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( @@ -468,6 +656,106 @@ impl Tty7App { ) } + /// Dispatch the byte read behind an untracked file's preview, at most + /// once per (path, snapshot). Runs from `render`, so the guards are the + /// point: `preview` says the answer is in hand, `preview_loading` says it + /// is on the way. + fn spawn_untracked_preview_if_needed(&mut self, cx: &mut Context) { + let want = { + let overlay = self + .tabs + .get(self.active) + .and_then(|t| t.diff_overlay.as_ref()); + match overlay { + Some(o) => match &o.load { + DiffLoad::Ready(snap) => { + untracked_focus(snap, o.focus.as_deref()).and_then(|path| { + let seen = o.preview.as_ref().is_some_and(|(held, _)| held == path) + || o.preview_loading.as_deref() == Some(path); + (!seen).then(|| (o.host_id, snap.root.clone(), path.to_string())) + }) + } + _ => None, + }, + None => None, + } + }; + let Some((host_id, root, path)) = want else { + return; + }; + let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, host_id) else { + return; + }; + let active = self.active; + if let Some(o) = self + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + { + o.preview_loading = Some(path.clone()); + } + let read_path = root.join(&path); + let key_path = path.clone(); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| { + h.read_file(&read_path, MAX_PREVIEW_BYTES) + .ok() + .map(|bytes| { + Arc::new(git_diff::synthesize_added( + &path, + &bytes, + &git_diff::DiffBudget::SINGLE_FILE, + )) + }) + }, + move |this, file, cx| { + let active = this.active; + let Some(o) = this + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + .filter(|o| o.host_id == host_id) + else { + return; + }; + if o.preview_loading.as_deref() == Some(key_path.as_str()) { + o.preview_loading = None; + } + o.preview = Some((key_path.clone(), file)); + cx.notify(); + }, + ); + } + + /// The one synthesized card, in the same scroll shell the file list uses. + fn diff_preview_card( + &self, + file: &FileDiff, + scroll: &gpui::ScrollHandle, + cx: &mut Context, + ) -> AnyElement { + let mode = view_mode(cx); + let list = v_flex() + .gap_3() + .p_4() + .w_full() + // `usize::MAX` keeps the element ids clear of the real list's. + .child(self.diff_file_card(usize::MAX, file, true, mode, cx)); + crate::ui::scrollbar::with_vertical_scrollbar( + "diff-overlay-scrollbar", + div() + .id("diff-overlay-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(scroll) + .child(list), + scroll, + ) + } + fn diff_message(&self, text: &'static str, cx: &Context) -> AnyElement { div() .flex_1() @@ -489,6 +777,7 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let stats = snap.stats(); + let mode = view_mode(cx); let oversized = focused.is_none() && stats.oversized; let mut list = v_flex().gap_3().p_4().w_full(); if oversized { @@ -507,7 +796,7 @@ impl Tty7App { } else { file_expanded(file, expanded, oversized) }; - list = list.child(self.diff_file_card(idx, file, is_expanded, cx)); + list = list.child(self.diff_file_card(idx, file, is_expanded, mode, cx)); } if focused.is_none() && snap.files.len() > shown { let rest = snap.files.len() - shown; @@ -531,7 +820,8 @@ impl Tty7App { "diff-overlay-scrollbar", div() .id("diff-overlay-scroll") - .size_full() + .flex_1() + .min_h_0() .overflow_y_scroll() .track_scroll(scroll) .child(list), @@ -568,16 +858,13 @@ impl Tty7App { idx: usize, file: &FileDiff, expanded: bool, + mode: DiffViewMode, cx: &mut Context, ) -> AnyElement { let expandable = !file.binary && (!file.hunks.is_empty() || file.truncated == Some(Truncation::Budget)); - let (glyph, glyph_color) = match file.status { - FileStatus::Added => ("A", cx.theme().success), - FileStatus::Modified => ("M", cx.theme().warning), - FileStatus::Deleted => ("D", cx.theme().danger), - FileStatus::Renamed => ("R", cx.theme().muted_foreground), - }; + let deco = deco_status(file.status); + let (glyph, glyph_color) = (status_glyph(deco), status_color(deco, cx)); let shown_path = match &file.old_path { Some(old) => format!("{old} → {}", file.path), None => file.path.clone(), @@ -684,7 +971,13 @@ impl Tty7App { let hunks: Vec<_> = file .hunks .iter() - .map(|hunk| (hunk, split_hunk(&hunk.lines))) + .map(|hunk| { + let rows = match mode { + DiffViewMode::Split => HunkRows::Split(split_hunk(&hunk.lines)), + DiffViewMode::Unified => HunkRows::Unified(unified_rows(&hunk.lines)), + }; + (hunk, rows) + }) .collect(); let closing_row = if file.truncated.is_some() { None @@ -707,8 +1000,25 @@ impl Tty7App { .truncate() .child(hunk.header.clone()), ); - for (r, row) in rows.iter().enumerate() { - body = body.child(self.diff_split_row(row, closing_row == Some((h, r)), cx)); + match rows { + HunkRows::Split(rows) => { + for (r, row) in rows.iter().enumerate() { + body = body.child(self.diff_split_row( + row, + closing_row == Some((h, r)), + cx, + )); + } + } + HunkRows::Unified(rows) => { + for (r, row) in rows.iter().enumerate() { + body = body.child(self.diff_unified_row( + row, + closing_row == Some((h, r)), + cx, + )); + } + } } } if let Some(reason) = file.truncated { @@ -792,6 +1102,78 @@ impl Tty7App { .into_any_element() } + /// One line of the unified view. + /// + /// Every measurement it shares with [`Self::diff_split_cell`] is shared on + /// purpose — the same 19px row, the same `text_xs` in the same family, and + /// above all the same `0.12` wash behind an addition and a removal. The two + /// views are one diff seen twice; a different green would read as a + /// different thing. + /// + /// What differs is forced by the shape. The line numbers get 34px a side + /// rather than 42 (there are two gutters here in front of one column of + /// text, not one in front of each), and the `+`/`−` gets a column of its + /// own rather than riding in the text: with three kinds of line stacked in + /// one column, an inlined marker would leave the context lines' code + /// starting two characters left of everything else. + fn diff_unified_row( + &self, + row: &UnifiedRow, + closes_card: bool, + cx: &Context, + ) -> AnyElement { + let radius = if closes_card { + rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE) + } else { + px(0.) + }; + let (marker_color, tint) = match row.kind { + LineKind::Added => (cx.theme().success, Some(cx.theme().success.opacity(0.12))), + LineKind::Removed => (cx.theme().danger, Some(cx.theme().danger.opacity(0.12))), + LineKind::Context => (cx.theme().muted_foreground, None), + }; + let gutter = |no: Option| { + h_flex() + .flex_shrink_0() + .w(px(34.)) + .justify_end() + .pr_1p5() + .text_color(cx.theme().muted_foreground.opacity(0.7)) + .child(no.map(|n| n.to_string()).unwrap_or_default()) + }; + h_flex() + .w_full() + .h(px(19.)) + .items_center() + .text_xs() + .font_family(self.font_family.clone()) + .rounded_bl(radius) + .rounded_br(radius) + .when_some(tint, |line, bg| line.bg(bg)) + .child(gutter(row.old)) + .child(gutter(row.new)) + // The split view's centre rule, in the one place it still means the + // same thing: everything left of it is a number, everything right + // of it is the file. + .child( + div() + .flex_shrink_0() + .w(px(1.)) + .h_full() + .bg(cx.theme().border), + ) + .child( + div() + .flex_shrink_0() + .w(px(12.)) + .text_center() + .text_color(marker_color) + .child(unified_marker(row.kind)), + ) + .child(div().flex_1().min_w_0().truncate().child(row.text.clone())) + .into_any_element() + } + fn diff_untracked_section(&self, snap: &DiffSnapshot, cx: &Context) -> AnyElement { let total = snap.untracked_count(); let untracked = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)]; @@ -832,8 +1214,8 @@ impl Tty7App { div() .flex_shrink_0() .font_weight(FontWeight::BOLD) - .text_color(cx.theme().success) - .child("A"), + .text_color(status_color(DecoStatus::Untracked, cx)) + .child(status_glyph(DecoStatus::Untracked)), ) .child(div().flex_1().min_w_0().truncate().child(path.clone())), ); @@ -854,11 +1236,143 @@ impl Tty7App { } } +/// Which layout the overlay draws. One setting for the window, not one per +/// overlay: VS Code's `diffEditor.renderSideBySide` is global for the same +/// reason — re-picking on every open is a chore, not a choice. +fn view_mode(cx: &gpui::App) -> DiffViewMode { + cx.try_global::() + .map(|cfg| cfg.diff_view) + .unwrap_or_default() +} + +/// The change column. `−` is U+2212, matching the split view: the ASCII hyphen +/// is narrower than `+` and the two columns would not line up. +fn unified_marker(kind: LineKind) -> &'static str { + match kind { + LineKind::Added => "+", + LineKind::Removed => "−", + LineKind::Context => "", + } +} + +/// The git status letter and colour every part of the app agrees on. +/// +/// `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. +pub(crate) fn deco_status(status: FileStatus) -> DecoStatus { + match status { + FileStatus::Added => DecoStatus::Added, + FileStatus::Modified => DecoStatus::Modified, + FileStatus::Deleted => DecoStatus::Deleted, + FileStatus::Renamed | FileStatus::Copied => DecoStatus::Renamed, + FileStatus::TypeChanged => DecoStatus::Modified, + FileStatus::Unmerged => DecoStatus::Conflict, + } +} + +/// The current epoch for a repository, or 0 where nothing has ever bumped one. +/// Zero is the same value a never-touched repository reports, so an overlay +/// that reads it before the global exists simply never looks stale. +fn scm_epoch(cx: &gpui::App, host: crate::ui::host_ops::HostId, root: &Path) -> u64 { + cx.try_global::() + .map(|data| data.epoch(host, root)) + .unwrap_or(0) +} + +/// What the header calls the patch it is showing. +struct SourceSubject { + icon: &'static str, + text: String, + /// 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 { + let branch_of = |chip| SourceSubject { + icon: "icons/git-branch.svg", + text: branch.clone(), + chip, + is_rev: false, + label: None, + }; + match source { + // Worktree and Head are both "the branch, right now"; the header for + // them is what it has always been. + DiffSource::Worktree | DiffSource::Head => branch_of(None), + // 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(t(L10nKey::ScmChipStaged))), + 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 { + let is_oid = rev.len() >= 40 && rev.chars().all(|c| c.is_ascii_hexdigit()); + match is_oid { + true => rev[..8].to_string(), + false => rev.to_string(), + } +} + fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option { let path = overlay.focus.as_deref()?; snap.files.iter().position(|f| f.path == path) } +/// The focused path, when it is an *untracked* file — one the snapshot lists +/// by name but holds no patch for. A path that is both (staged half tracked, +/// say) prefers the real patch. +fn untracked_focus<'a>(snap: &DiffSnapshot, focus: Option<&'a str>) -> Option<&'a str> { + let path = focus?; + if snap.files.iter().any(|f| f.path == path) { + return None; + } + snap.untracked.iter().any(|u| u == path).then_some(path) +} + fn focused_name(overlay: &DiffOverlayState) -> Option { let DiffLoad::Ready(snap) = &overlay.load else { return None; @@ -913,76 +1427,23 @@ fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String { parts.join(", ") } -#[derive(Clone, Copy)] -enum Side { - Old, - New, -} - -struct SplitCell { - no: Option, - text: String, - changed: bool, -} - -struct SplitRow { - left: Option, - right: Option, -} - -fn split_hunk(lines: &[git_diff::DiffLine]) -> Vec { - fn clean(text: &str) -> String { - text.replace('\t', " ") - } - fn flush( - rows: &mut Vec, - rem: &mut Vec<&git_diff::DiffLine>, - add: &mut Vec<&git_diff::DiffLine>, - ) { - for i in 0..rem.len().max(add.len()) { - rows.push(SplitRow { - left: rem.get(i).map(|l| SplitCell { - no: l.old_no, - text: clean(&l.text), - changed: true, - }), - right: add.get(i).map(|l| SplitCell { - no: l.new_no, - text: clean(&l.text), - changed: true, - }), - }); - } - rem.clear(); - add.clear(); - } - - let mut rows = Vec::new(); - let mut rem: Vec<&git_diff::DiffLine> = Vec::new(); - let mut add: Vec<&git_diff::DiffLine> = Vec::new(); - for line in lines { - match line.kind { - LineKind::Removed => rem.push(line), - LineKind::Added => add.push(line), - LineKind::Context => { - flush(&mut rows, &mut rem, &mut add); - rows.push(SplitRow { - left: Some(SplitCell { - no: line.old_no, - text: clean(&line.text), - changed: false, - }), - right: Some(SplitCell { - no: line.new_no, - text: clean(&line.text), - changed: false, - }), - }); - } - } - } - flush(&mut rows, &mut rem, &mut add); - rows +/// 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. +/// +/// `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!("{}\u{1}", source.tag())); + tagged.push(cwd.as_os_str()); + (host, PathBuf::from(tagged)) } #[cfg(test)] @@ -1025,42 +1486,209 @@ mod tests { } #[test] - fn pairs_removed_and_added_side_by_side() { - let lines = vec![ - line(LineKind::Context, Some(1), Some(1), "a"), - line(LineKind::Removed, Some(2), None, "b"), - line(LineKind::Removed, Some(3), None, "c"), - line(LineKind::Added, None, Some(2), "B"), - line(LineKind::Context, Some(4), Some(3), "d"), - ]; - let rows = split_hunk(&lines); - assert_eq!(rows.len(), 4); - - let l = rows[0].left.as_ref().unwrap(); - let r = rows[0].right.as_ref().unwrap(); - assert_eq!((l.no, l.text.as_str(), l.changed), (Some(1), "a", false)); - assert_eq!((r.no, r.text.as_str(), r.changed), (Some(1), "a", false)); - - let l = rows[1].left.as_ref().unwrap(); - let r = rows[1].right.as_ref().unwrap(); - assert_eq!((l.no, l.text.as_str(), l.changed), (Some(2), "b", true)); - assert_eq!((r.no, r.text.as_str(), r.changed), (Some(2), "B", true)); - - assert_eq!(rows[2].left.as_ref().unwrap().text, "c"); - assert!(rows[2].right.is_none()); - - assert_eq!(rows[3].left.as_ref().unwrap().no, Some(4)); - assert_eq!(rows[3].right.as_ref().unwrap().no, Some(3)); + fn the_probe_key_separates_the_sources_over_one_directory() { + let host = crate::ui::host_ops::HostId::LOCAL; + let cwd = Path::new("/repo"); + let worktree = probe_key(host, cwd, &DiffSource::Worktree); + 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("a")), + probe_key(host, cwd, &DiffSource::commit("b")), + "two commits are two probes" + ); + assert_eq!(worktree, probe_key(host, cwd, &DiffSource::Worktree)); + assert_ne!( + 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] - fn expands_tabs_in_cell_text() { - let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")]; - let rows = split_hunk(&lines); - assert_eq!(rows[0].right.as_ref().unwrap().text, " indented"); - assert!(rows[0].left.is_none()); + fn every_file_status_lands_on_a_shared_decoration() { + use DecoStatus as D; + for (status, want) in [ + (FileStatus::Added, D::Added), + (FileStatus::Modified, D::Modified), + (FileStatus::Deleted, D::Deleted), + (FileStatus::Renamed, D::Renamed), + // A copy is a rename that left the original behind: same letter. + (FileStatus::Copied, D::Renamed), + // A symlink that became a file is a modification, not a category + // of its own — the overlay is the only place that ever saw a `T`. + (FileStatus::TypeChanged, D::Modified), + (FileStatus::Unmerged, D::Conflict), + ] { + assert_eq!(deco_status(status), want, "{status:?}"); + } + assert_eq!(status_glyph(deco_status(FileStatus::Unmerged)), "U"); + assert_eq!(status_glyph(deco_status(FileStatus::Copied)), "R"); } + #[test] + fn the_change_column_uses_the_typographic_minus() { + assert_eq!(unified_marker(LineKind::Added), "+"); + assert_eq!(unified_marker(LineKind::Removed), "\u{2212}"); + assert_ne!( + unified_marker(LineKind::Removed), + "-", + "the ASCII hyphen is narrower than `+`, and the column would wobble" + ); + assert_eq!( + unified_marker(LineKind::Context), + "", + "a context line is neither, and a placeholder glyph would be noise" + ); + } + + #[test] + fn the_header_shortens_an_object_id_and_nothing_else() { + let oid = "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"; + assert_eq!(short_rev(oid), "3f2a1b9c"); + assert_eq!(short_rev("v26.7.5"), "v26.7.5"); + assert_eq!( + short_rev("origin/main"), + "origin/main", + "half a ref name says less than the whole of it" + ); + assert_eq!(short_rev("3f2a1b9"), "3f2a1b9", "already short"); + } + + #[test] + fn each_source_names_itself_in_the_header() { + let branch = || "main".to_string(); + let plain = source_subject(&DiffSource::Worktree, branch()); + assert_eq!((plain.icon, plain.text.as_str()), (BRANCH_ICON, "main")); + assert_eq!(plain.chip, None); + assert!(!plain.is_rev); + + assert_eq!(source_subject(&DiffSource::Head, branch()).chip, None); + + let staged = source_subject(&DiffSource::Staged, branch()); + assert_eq!(staged.icon, BRANCH_ICON, "still a branch, still its name"); + assert_eq!( + staged.chip, + Some("STAGED"), + "without it the staged patch is indistinguishable from the unstaged one" + ); + + let commit = source_subject( + &DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), + branch(), + ); + assert_eq!(commit.icon, COMMIT_ICON); + assert_eq!(commit.text, "3f2a1b9c", "the branch is not what is shown"); + assert!(commit.is_rev); + + let range = source_subject( + &DiffSource::Range { + base: "main".into(), + head: "feature".into(), + }, + branch(), + ); + assert_eq!(range.icon, COMMIT_ICON); + 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"; + fn small_file(path: &str, added: u32) -> FileDiff { FileDiff { path: path.to_string(), @@ -1277,6 +1905,23 @@ mod tests { ); } + #[test] + fn a_focused_untracked_file_asks_for_a_preview_not_the_list() { + let snap = DiffSnapshot { + files: vec![small_file("tracked.rs", 3)], + untracked: vec!["new.md".to_string()], + ..Default::default() + }; + assert_eq!(untracked_focus(&snap, Some("new.md")), Some("new.md")); + assert_eq!( + untracked_focus(&snap, Some("tracked.rs")), + None, + "a real patch wins over the name list" + ); + assert_eq!(untracked_focus(&snap, Some("absent.rs")), None); + assert_eq!(untracked_focus(&snap, None), None); + } + #[test] fn untracked_rows_are_capped_but_the_count_stays_true() { let snap = DiffSnapshot { @@ -1450,3 +2095,225 @@ mod tests { } } } + +#[cfg(all(test, unix))] +mod overlay_gpui_tests { + use super::*; + use crate::ui::app::test_window; + use crate::ui::host_ops::HostId; + use gpui::{Entity, TestAppContext, VisualTestContext}; + + fn overlay_source_and_load( + app: &Entity, + vcx: &mut VisualTestContext, + ) -> (DiffSource, bool) { + app.update_in(vcx, |app, _, _| { + let overlay = app.tabs[app.active] + .diff_overlay + .as_ref() + .expect("an overlay is open"); + ( + overlay.source.clone(), + matches!(overlay.load, DiffLoad::Loading), + ) + }) + } + + /// Opening a staged file while a worktree overlay is up must re-probe. The + /// filter used to match on `(cwd, host)` alone, so it took the "just move + /// the focus" branch and left the unstaged patch on screen under the + /// staged file's name. + #[gpui::test] + fn a_second_source_over_one_directory_is_a_second_overlay(cx: &mut TestAppContext) { + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + let cwd = std::path::PathBuf::from("/no/such/tty7/repo"); + + app.update_in(&mut vcx, |app, window, cx| { + app.open_diff_overlay( + HostId::LOCAL, + cwd.clone(), + DiffSource::Worktree, + Some("a.rs".to_string()), + window, + cx, + ); + }); + assert_eq!( + overlay_source_and_load(&app, &mut vcx), + (DiffSource::Worktree, true) + ); + + // Pretend the worktree probe landed, so a reused overlay would show it. + app.update_in(&mut vcx, |app, _, _| { + let active = app.active; + let overlay = app.tabs[active].diff_overlay.as_mut().unwrap(); + overlay.loading = false; + overlay.load = DiffLoad::Ready(Arc::new(DiffSnapshot { + source: DiffSource::Worktree, + branch: "main".into(), + ..Default::default() + })); + }); + + app.update_in(&mut vcx, |app, window, cx| { + app.open_diff_overlay( + HostId::LOCAL, + cwd.clone(), + DiffSource::Staged, + Some("a.rs".to_string()), + window, + cx, + ); + }); + assert_eq!( + overlay_source_and_load(&app, &mut vcx), + (DiffSource::Staged, true), + "the same file from a different source is a different question" + ); + } + + fn one_file_snapshot(source: DiffSource) -> DiffSnapshot { + use crate::terminal::git_diff::{DiffLine, Hunk, LineKind}; + DiffSnapshot { + root: std::path::PathBuf::from("/no/such/tty7/repo"), + source, + branch: "main".into(), + files: vec![FileDiff { + path: "a.rs".into(), + old_path: None, + status: FileStatus::Modified, + added: 1, + removed: 1, + binary: false, + truncated: None, + hunks: vec![Hunk { + header: "@@ -1,2 +1,2 @@".into(), + lines: vec![ + DiffLine { + kind: LineKind::Context, + old_no: Some(1), + new_no: Some(1), + text: "keep".into(), + }, + DiffLine { + kind: LineKind::Removed, + old_no: Some(2), + new_no: None, + text: "old".into(), + }, + DiffLine { + kind: LineKind::Added, + old_no: None, + new_no: Some(2), + text: "new".into(), + }, + ], + }], + }], + untracked: vec!["scratch.txt".into()], + untracked_total: 1, + read_failed: false, + } + } + + fn show(app: &Entity, vcx: &mut VisualTestContext, source: DiffSource) { + let cwd = std::path::PathBuf::from("/no/such/tty7/repo"); + app.update_in(vcx, |app, window, cx| { + app.open_diff_overlay(HostId::LOCAL, cwd.clone(), source.clone(), None, window, cx); + let active = app.active; + let overlay = app.tabs[active].diff_overlay.as_mut().unwrap(); + overlay.loading = false; + overlay.load = DiffLoad::Ready(Arc::new(one_file_snapshot(source))); + // The card is what carries the rows, so open it. + overlay.expanded.insert("a.rs".to_string(), true); + }); + } + + /// Every header branch, every row renderer, once each. A missing icon, an + /// unset global or a panicking helper shows up here rather than the first + /// time somebody opens a commit. + #[gpui::test] + fn every_source_renders_in_both_views(cx: &mut TestAppContext) { + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + + for mode in [DiffViewMode::Split, DiffViewMode::Unified] { + app.update_in(&mut vcx, |app, _, cx| { + app.update_config(cx, |cfg| cfg.diff_view = mode); + }); + for source in [ + 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(), + head: "feature".into(), + }, + ] { + show(&app, &mut vcx, source.clone()); + // A real frame, so layout and paint run too: `title_bar_drag` + // and the segmented track both want a window that is drawing. + crate::ui::app::render_probe::arm(10_000); + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + assert!( + crate::ui::app::render_probe::draws() > 0, + "nothing was drawn, so nothing was proved: {source:?} in {mode:?}" + ); + app.update_in(&mut vcx, |app, window, cx| { + app.close_diff_overlay(window, cx) + }); + } + } + } + + #[gpui::test] + fn toggling_the_diff_view_mode_writes_config(cx: &mut TestAppContext) { + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + let mode = + |vcx: &mut VisualTestContext| vcx.update(|_, cx| cx.global::().diff_view); + + assert_eq!( + mode(&mut vcx), + DiffViewMode::Split, + "side by side is what everyone already sees" + ); + + app.update_in(&mut vcx, |app, _, cx| app.toggle_diff_view_mode(cx)); + assert_eq!(mode(&mut vcx), DiffViewMode::Unified); + + app.update_in(&mut vcx, |app, _, cx| app.toggle_diff_view_mode(cx)); + assert_eq!(mode(&mut vcx), DiffViewMode::Split, "and back again"); + } + + /// The same source and the same focus still toggles the overlay shut. + #[gpui::test] + fn the_same_source_twice_still_closes(cx: &mut TestAppContext) { + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + let cwd = std::path::PathBuf::from("/no/such/tty7/repo"); + + for _ in 0..2 { + app.update_in(&mut vcx, |app, window, cx| { + app.open_diff_overlay( + HostId::LOCAL, + cwd.clone(), + DiffSource::Worktree, + None, + window, + cx, + ); + }); + } + app.update_in(&mut vcx, |app, _, _| { + assert!(app.tabs[app.active].diff_overlay.is_none()); + }); + } +} diff --git a/src/ui/diff_rows.rs b/src/ui/diff_rows.rs new file mode 100644 index 00000000..4aa16876 --- /dev/null +++ b/src/ui/diff_rows.rs @@ -0,0 +1,242 @@ +//! Turning a parsed hunk into rows a diff view can lay out. +//! +//! Side-by-side and unified are two renderings of the same `Vec`, so +//! the pairing logic lives here — outside either renderer — and is unit tested +//! without a window. + +use crate::terminal::git_diff::{DiffLine, LineKind}; + +/// A tab is worth this many columns. Not configurable: a diff is read next to +/// the file's other lines, not on its own, and the grid has to line up. +const TAB_WIDTH: usize = 4; + +/// Diff text is laid out as a single run, so a literal tab would advance to the +/// renderer's idea of a tab stop rather than the file's. Both views expand +/// them the same way, or the two halves of a split row would drift apart. +pub(crate) fn expand_tabs(text: &str) -> String { + text.replace('\t', &" ".repeat(TAB_WIDTH)) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Side { + Old, + New, +} + +pub(crate) struct SplitCell { + pub(crate) no: Option, + pub(crate) text: String, + pub(crate) changed: bool, +} + +pub(crate) struct SplitRow { + pub(crate) left: Option, + pub(crate) right: Option, +} + +/// Pairs each run of removals with the run of additions that follows it, so a +/// rewritten line sits opposite the line it replaced. Whichever run is shorter +/// leaves empty cells at the bottom of the pair. +pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec { + fn flush(rows: &mut Vec, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) { + for i in 0..rem.len().max(add.len()) { + rows.push(SplitRow { + left: rem.get(i).map(|l| SplitCell { + no: l.old_no, + text: expand_tabs(&l.text), + changed: true, + }), + right: add.get(i).map(|l| SplitCell { + no: l.new_no, + text: expand_tabs(&l.text), + changed: true, + }), + }); + } + rem.clear(); + add.clear(); + } + + let mut rows = Vec::new(); + let mut rem: Vec<&DiffLine> = Vec::new(); + let mut add: Vec<&DiffLine> = Vec::new(); + for line in lines { + match line.kind { + LineKind::Removed => rem.push(line), + LineKind::Added => add.push(line), + LineKind::Context => { + flush(&mut rows, &mut rem, &mut add); + rows.push(SplitRow { + left: Some(SplitCell { + no: line.old_no, + text: expand_tabs(&line.text), + changed: false, + }), + right: Some(SplitCell { + no: line.new_no, + text: expand_tabs(&line.text), + changed: false, + }), + }); + } + } + } + flush(&mut rows, &mut rem, &mut add); + rows +} + +pub(crate) struct UnifiedRow { + pub(crate) old: Option, + pub(crate) new: Option, + pub(crate) kind: LineKind, + pub(crate) text: String, +} + +/// One row per line, in git's own order — every removal in a run first, then +/// every addition. That is the opposite of [`split_hunk`], and it is the whole +/// difference between the two views: unified shows the patch as it was written, +/// side-by-side re-pairs it into before and after. +pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec { + lines + .iter() + .map(|line| UnifiedRow { + old: line.old_no, + new: line.new_no, + kind: line.kind, + text: expand_tabs(&line.text), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(kind: LineKind, old: Option, new: Option, text: &str) -> DiffLine { + DiffLine { + kind, + old_no: old, + new_no: new, + text: text.to_string(), + } + } + + /// A context line, two removals, one addition, a context line — the shape + /// where the two views visibly disagree. + fn hunk() -> Vec { + vec![ + line(LineKind::Context, Some(1), Some(1), "a"), + line(LineKind::Removed, Some(2), None, "b"), + line(LineKind::Removed, Some(3), None, "c"), + line(LineKind::Added, None, Some(2), "B"), + line(LineKind::Context, Some(4), Some(3), "d"), + ] + } + + #[test] + fn pairs_removed_and_added_side_by_side() { + let rows = split_hunk(&hunk()); + assert_eq!(rows.len(), 4); + + let l = rows[0].left.as_ref().unwrap(); + let r = rows[0].right.as_ref().unwrap(); + assert_eq!((l.no, l.text.as_str(), l.changed), (Some(1), "a", false)); + assert_eq!((r.no, r.text.as_str(), r.changed), (Some(1), "a", false)); + + let l = rows[1].left.as_ref().unwrap(); + let r = rows[1].right.as_ref().unwrap(); + assert_eq!((l.no, l.text.as_str(), l.changed), (Some(2), "b", true)); + assert_eq!((r.no, r.text.as_str(), r.changed), (Some(2), "B", true)); + + assert_eq!(rows[2].left.as_ref().unwrap().text, "c"); + assert!(rows[2].right.is_none()); + + assert_eq!(rows[3].left.as_ref().unwrap().no, Some(4)); + assert_eq!(rows[3].right.as_ref().unwrap().no, Some(3)); + } + + #[test] + fn expands_tabs_in_cell_text() { + let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")]; + let rows = split_hunk(&lines); + assert_eq!(rows[0].right.as_ref().unwrap().text, " indented"); + assert!(rows[0].left.is_none()); + } + + #[test] + fn expand_tabs_is_a_fixed_width_substitution() { + assert_eq!(expand_tabs("plain"), "plain"); + assert_eq!(expand_tabs("\tone"), " one"); + assert_eq!(expand_tabs("\t\ttwo"), " two"); + assert_eq!( + expand_tabs("a\tb"), + "a b", + "a fixed width, not the next tab stop — the diff has no column grid" + ); + assert_eq!(expand_tabs(""), ""); + } + + #[test] + fn unified_keeps_gits_own_order() { + let rows = unified_rows(&hunk()); + let shape: Vec<(Option, Option, LineKind, &str)> = rows + .iter() + .map(|r| (r.old, r.new, r.kind, r.text.as_str())) + .collect(); + assert_eq!( + shape, + [ + (Some(1), Some(1), LineKind::Context, "a"), + (Some(2), None, LineKind::Removed, "b"), + (Some(3), None, LineKind::Removed, "c"), + (None, Some(2), LineKind::Added, "B"), + (Some(4), Some(3), LineKind::Context, "d"), + ], + "both removals come before the addition, unlike the split view" + ); + } + + #[test] + fn unified_numbers_each_column_from_the_side_it_belongs_to() { + let rows = unified_rows(&hunk()); + assert!( + rows.iter() + .all(|r| (r.old.is_some() && r.new.is_some()) == (r.kind == LineKind::Context)), + "a context line is the only kind that exists on both sides" + ); + assert!( + rows.iter() + .filter(|r| r.kind == LineKind::Added) + .all(|r| r.old.is_none()) + ); + assert!( + rows.iter() + .filter(|r| r.kind == LineKind::Removed) + .all(|r| r.new.is_none()) + ); + } + + #[test] + fn unified_expands_tabs_the_same_way_split_does() { + let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")]; + assert_eq!(unified_rows(&lines)[0].text, " indented"); + } + + #[test] + fn both_views_render_every_line_exactly_once() { + let lines = hunk(); + let unified = unified_rows(&lines); + assert_eq!(unified.len(), lines.len()); + + let cells: usize = split_hunk(&lines) + .iter() + .map(|r| r.left.is_some() as usize + r.right.is_some() as usize) + .sum(); + let context = lines.iter().filter(|l| l.kind == LineKind::Context).count(); + assert_eq!( + cells, + lines.len() + context, + "a context line fills two cells, a change fills one" + ); + } +} diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 6378f1b6..08a14a1f 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1,13 +1,18 @@ +use std::borrow::Cow; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::core::config::RightPanelTab; +use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex}; +use crate::terminal::git_data::index_of; use crate::ui::app::Tty7App; use crate::ui::file_copy; use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub}; use crate::ui::host_registry::HostRegistry; use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::git_badge; +use crate::ui::scm::status::{status_color, status_glyph}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton, @@ -815,6 +820,21 @@ impl Tty7App { }) { roots_moved = self.file_tree.invalidate_repo_roots(); } + // Working-tree edits the source control cache has no other way to hear + // about. Anything under `.git` is skipped: the repository has its own + // watch, and routing it through here would only double the events that + // land in one debounce window. + let mut announced: HashSet<&Path> = HashSet::new(); + for path in paths { + if path.components().any(|c| c.as_os_str() == ".git") { + continue; + } + let Some(dir) = path.parent() else { continue }; + if announced.insert(dir) { + self.scm_invalidate_cwd(host, dir, cx); + } + } + let gitignore_touched = paths .iter() .any(|p| p.file_name().is_some_and(|n| n == ".gitignore")); @@ -1369,6 +1389,7 @@ impl Tty7App { if let Some(host) = host.clone() { self.file_tree_sync_watch(host, cx); } + let decor = self.file_tree_decorations(host.as_ref(), host_id, &roots, cx); self.file_tree.sync_search(&query, &roots, cx); let rows = if self.file_tree_searching(cx) { self.file_tree.search_rows() @@ -1406,10 +1427,10 @@ impl Tty7App { this.file_tree_key_down(ev, window, cx); })) .children(blank) - .children( - rows.iter() - .flat_map(|row| self.render_tree_row(row, window, cx)), - ) + .children(rows.iter().flat_map(|row| { + let deco = row_decoration(&decor, &row.entry); + self.render_tree_row(row, deco, window, cx) + })) // Everything the rows do not cover — the gap below the last one, // and the whole column while the tree is still empty — belongs to // the top of the tree. A row under the cursor wins: gpui hands a @@ -1431,9 +1452,36 @@ impl Tty7App { ) } + /// Ask each root for a fresh status and take the index it already holds. + /// + /// The `Arc` is cloned here, outside the row loop: a tree can be thousands + /// of rows and every one of them wants the same index. `scm_refresh` is + /// idempotent and drops a probe that is already running or already current, + /// which is what makes it safe from a render. + fn file_tree_decorations( + &mut self, + host: Option<&SharedHost>, + host_id: HostId, + roots: &[PathBuf], + cx: &mut Context, + ) -> Decorations { + let mut decor: Decorations = Vec::new(); + for root in roots { + if let Some(host) = host { + self.scm_refresh(host.clone(), root.clone(), cx); + } + if let Some(index) = index_of(cx, host_id, root) { + decor.push((root.clone(), index)); + } + } + order_innermost_first(&mut decor); + decor + } + fn render_tree_row( &self, row: &TreeRow, + deco: RowDeco, _window: &mut Window, cx: &mut Context, ) -> Vec { @@ -1527,6 +1575,13 @@ impl Tty7App { .when(row.entry.ignored, |d| { d.italic().text_color(muted.opacity(0.7)) }) + // The name carrying the colour is the signal people actually + // read; the letter at the end of the row is the confirmation. + .when_some(deco.tint, |d, status| { + d.text_color(status_color(status, cx)) + }) + .when(deco.strike, |d| d.line_through()) + .when(deco.bold, |d| d.font_weight(gpui::FontWeight::SEMIBOLD)) .when(row.is_root, |d| d.font_weight(gpui::FontWeight::MEDIUM)) .child(SharedString::from(row.entry.name.clone())) .into_any_element() @@ -1549,6 +1604,10 @@ impl Tty7App { muted })) .child(label) + // Two indicators, two columns, two shapes. The dot is an unsaved + // editor buffer and has nothing to do with git; keeping it round and + // `warning` while the git letter sits in its own trailing cell is + // what stops the two from ever being read as one. .when(dirty, |d| { d.child( div() @@ -1558,6 +1617,13 @@ impl Tty7App { .bg(cx.theme().warning), ) }) + .when_some(deco.badge(), |d, (letter, status)| { + d.child(git_badge( + letter, + status_color(status, cx), + &cx.theme().mono_font_family, + )) + }) .on_mouse_down( MouseButton::Left, cx.listener({ @@ -1843,6 +1909,117 @@ fn dirs_to_relist(paths: &HashSet, show_hidden: bool) -> HashSet<&Path> .collect() } +/// Every repository behind the tree, paired with the root its index keys are +/// relative to. Built once per render and ordered innermost-first. +type Decorations = Vec<(PathBuf, Arc)>; + +/// What git says about one row: a letter for the trailing badge and the shape +/// of the name beside it. +/// +/// The colour is carried as a `DecoStatus` rather than an `Hsla` so that it +/// resolves through the one table in `scm::status` — the panel, the diff cards +/// and the tree cannot grow three opinions about what "modified" looks like — +/// and so that everything below stays a pure function. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +struct RowDeco { + /// Empty wherever no badge is drawn: directories, because a folder is not + /// "M", and ignored rows, because a tree full of `!` is noise. + letter: &'static str, + tint: Option, + strike: bool, + bold: bool, +} + +impl RowDeco { + fn file(status: DecoStatus) -> RowDeco { + RowDeco { + letter: status_glyph(status), + tint: Some(status), + // The name says "gone" twice — struck through and greyed — because + // the row still occupies a slot in a listing that no longer has the + // file in it. + strike: status == DecoStatus::Deleted, + bold: status == DecoStatus::Conflict, + } + } + + /// A directory is two states, never seven: work happened under it, or a + /// conflict is waiting under it. `Modified` and `Conflict` appear here only + /// as the way to reach `warning` and `danger` through the shared table. + fn dir(rollup: DirRollup) -> RowDeco { + let tint = if rollup.conflict { + Some(DecoStatus::Conflict) + } else if rollup.changed { + Some(DecoStatus::Modified) + } else { + None + }; + RowDeco { + tint, + ..RowDeco::default() + } + } + + /// The badge is laid out only where there is a letter for it, so a tree with + /// no repository behind it gives up no width. + fn badge(&self) -> Option<(&'static str, DecoStatus)> { + let status = self.tint?; + (!self.letter.is_empty()).then_some((self.letter, status)) + } +} + +/// `StatusIndex` is keyed by a repo-root-relative, `/`-separated path. Borrowed +/// rather than built, which on Unix is every row. +fn repo_relative<'a>(root: &Path, path: &'a Path) -> Option> { + let rel = path.strip_prefix(root).ok()?.to_str()?; + if rel.is_empty() { + // The root row itself, which has no key and nothing worth saying: + // "this repository contains changes" is not news. + return None; + } + Some(with_forward_slashes(rel, std::path::MAIN_SEPARATOR)) +} + +/// Split out of `repo_relative` so the Windows separator is reachable from a +/// test on any platform — `strip_prefix` only ever splits on the host's own +/// separator, which leaves a backslash path untestable through the caller. +fn with_forward_slashes(text: &str, sep: char) -> Cow<'_, str> { + if sep == '/' || !text.contains(sep) { + return Cow::Borrowed(text); + } + Cow::Owned(text.replace(sep, "/")) +} + +/// Innermost first, so a submodule nested inside another root answers for its +/// own files instead of the repository that contains it. +fn order_innermost_first(decor: &mut Decorations) { + decor.sort_by_key(|(root, _)| std::cmp::Reverse(root.as_os_str().len())); +} + +/// One hash probe per row and no allocation on the path that matters. +fn row_decoration(decor: &Decorations, entry: &TreeEntry) -> RowDeco { + // A gitignored row keeps the italic-and-dim it has always worn and takes + // nothing else: a letter and a colour would be describing a file the + // repository is not tracking. + if entry.ignored { + return RowDeco::default(); + } + for (root, index) in decor { + let Some(rel) = repo_relative(root, &entry.path) else { + continue; + }; + return if entry.is_dir { + index.dir(&rel).map(RowDeco::dir).unwrap_or_default() + } else { + // `file` comes back empty once the change count blew past + // `MAX_DECORATED_FILES`. The rollups survive that, so the folders + // keep saying where the work is. + index.file(&rel).map(RowDeco::file).unwrap_or_default() + }; + } + RowDeco::default() +} + fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool { show_hidden || !path @@ -2024,6 +2201,210 @@ mod tests { assert_eq!(names, vec!["Alpha", "beta", "Apple.rs", "zeta.rs"]); } + fn tree_entry(path: &str, is_dir: bool, ignored: bool) -> TreeEntry { + let path = PathBuf::from(path); + TreeEntry { + name: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + path, + is_dir, + ignored, + } + } + + fn one_repo(paths: &[(&str, DecoStatus)]) -> Decorations { + let mut index = StatusIndex::default(); + for (path, status) in paths { + index.insert(path, *status); + } + vec![(PathBuf::from("/repo"), Arc::new(index))] + } + + #[test] + fn a_row_is_keyed_by_where_it_sits_below_the_repository_root() { + let root = Path::new("/repo"); + assert!( + repo_relative(root, Path::new("/repo")).is_none(), + "the root row has no key of its own" + ); + assert_eq!( + repo_relative(root, Path::new("/repo/README.md")).as_deref(), + Some("README.md") + ); + assert_eq!( + repo_relative(root, Path::new("/repo/src/ui/file_tree.rs")).as_deref(), + Some("src/ui/file_tree.rs") + ); + assert!( + repo_relative(root, Path::new("/elsewhere/a.rs")).is_none(), + "a row outside the repository is not decorated" + ); + assert!( + repo_relative(root, Path::new("/repository/a.rs")).is_none(), + "a shared text prefix is not a shared root" + ); + } + + #[test] + fn a_windows_path_is_keyed_with_forward_slashes() { + assert_eq!( + with_forward_slashes(r"src\ui\file_tree.rs", '\\'), + "src/ui/file_tree.rs" + ); + assert_eq!(with_forward_slashes("README.md", '\\'), "README.md"); + assert_eq!( + with_forward_slashes("src/ui/file_tree.rs", '/'), + "src/ui/file_tree.rs" + ); + // The rows that exist in their thousands must not allocate a key. + assert!(matches!( + with_forward_slashes("src/ui/file_tree.rs", '/'), + Cow::Borrowed(_) + )); + assert!(matches!( + with_forward_slashes("README.md", '\\'), + Cow::Borrowed(_) + )); + } + + #[test] + fn every_status_gets_its_letter_and_its_own_name_shape() { + // (status, letter, bold, struck through) + let cases = [ + (DecoStatus::Conflict, "U", true, false), + (DecoStatus::Deleted, "D", false, true), + (DecoStatus::Added, "A", false, false), + (DecoStatus::Untracked, "?", false, false), + (DecoStatus::Modified, "M", false, false), + (DecoStatus::Renamed, "R", false, false), + ]; + for (status, letter, bold, strike) in cases { + let deco = RowDeco::file(status); + assert_eq!(deco.letter, letter, "{status:?}"); + assert_eq!( + deco.tint, + Some(status), + "{status:?} colours the name through the shared table" + ); + assert_eq!(deco.bold, bold, "{status:?}"); + assert_eq!(deco.strike, strike, "{status:?}"); + assert_eq!(deco.badge(), Some((letter, status)), "{status:?}"); + } + + let ignored = RowDeco::file(DecoStatus::Ignored); + assert_eq!(ignored.letter, ""); + assert!( + ignored.badge().is_none(), + "a tree full of `!` is noise, not information" + ); + } + + #[test] + fn a_folder_is_only_ever_changed_or_conflicted_and_never_lettered() { + assert_eq!(RowDeco::dir(DirRollup::default()), RowDeco::default()); + + let changed = RowDeco::dir(DirRollup { + changed: true, + conflict: false, + }); + assert_eq!( + changed.tint, + Some(DecoStatus::Modified), + "the same warning a modified file wears" + ); + assert_eq!(changed.letter, ""); + assert!(changed.badge().is_none(), "a folder is not `M`"); + + let conflict = RowDeco::dir(DirRollup { + changed: true, + conflict: true, + }); + assert_eq!( + conflict.tint, + Some(DecoStatus::Conflict), + "a conflict below outranks a mere change below" + ); + assert_eq!(conflict.letter, ""); + assert!( + !conflict.bold, + "the folder points; the file inside it shouts" + ); + } + + #[test] + fn dropping_the_file_map_leaves_the_folders_decorated() { + let mut index = StatusIndex::default(); + index.insert("src/ui/a.rs", DecoStatus::Modified); + index.drop_files(); + let decor: Decorations = vec![(PathBuf::from("/repo"), Arc::new(index))]; + + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/src/ui/a.rs", false, false)), + RowDeco::default(), + "no letter survives the circuit breaker" + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/src", true, false)).tint, + Some(DecoStatus::Modified), + "but the folders still say where the work is" + ); + } + + #[test] + fn a_gitignored_row_is_left_with_the_styling_it_already_had() { + let decor = one_repo(&[("target/debug/app", DecoStatus::Untracked)]); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/target/debug/app", false, false)).letter, + "?", + "the same row without the ignore flag is decorated" + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/target/debug/app", false, true)), + RowDeco::default(), + "italic and dim is the whole of what an ignored row says" + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/target", true, true)), + RowDeco::default(), + "and an ignored folder does not get a rollup colour either" + ); + } + + #[test] + fn the_innermost_repository_answers_for_its_own_rows() { + let mut outer = StatusIndex::default(); + outer.insert("vendor/lib/a.rs", DecoStatus::Modified); + let mut inner = StatusIndex::default(); + inner.insert("a.rs", DecoStatus::Conflict); + let mut decor: Decorations = vec![ + (PathBuf::from("/repo"), Arc::new(outer)), + (PathBuf::from("/repo/vendor/lib"), Arc::new(inner)), + ]; + order_innermost_first(&mut decor); + + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/vendor/lib/a.rs", false, false)).letter, + "U", + "the submodule, not the repository holding it" + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/vendor", true, false)).tint, + Some(DecoStatus::Modified), + "the outer repository still rolls its own directories up" + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/elsewhere/a.rs", false, false)), + RowDeco::default() + ); + assert_eq!( + row_decoration(&decor, &tree_entry("/repo/README.md", false, false)), + RowDeco::default(), + "a clean tracked file is left alone" + ); + } + #[test] fn shell_quote_leaves_safe_paths_and_quotes_the_rest() { assert_eq!(shell_quote(Path::new("/a/b.txt")), "/a/b.txt"); @@ -2356,6 +2737,50 @@ mod render_idle_gpui_tests { panic!("the tree never went quiet"); } + /// Runs git with the identity and signing pinned, so the test does not + /// depend on whatever is in the developer's `~/.gitconfig`. + fn git(root: &Path, args: &[&str]) -> bool { + let mut full = vec![ + "-c", + "user.name=tty7 test", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + ]; + full.extend_from_slice(args); + std::process::Command::new("git") + .args(&full) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + /// The status probe only goes out from `render`, so this drives frames + /// until the index it produces is on the global. + fn scm_index( + app: &Entity, + vcx: &mut VisualTestContext, + root: &Path, + ) -> Arc { + 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 let Some(index) = app.update_in(vcx, |_, _, cx| index_of(cx, HostId::LOCAL, root)) { + return index; + } + assert!( + std::time::Instant::now() < deadline, + "the repository status never landed" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { render_probe::arm(BUDGET); vcx.background_executor.run_until_parked(); @@ -2383,6 +2808,54 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } + #[gpui::test] + fn a_decorated_tree_settles_and_then_reaches_render_idle(cx: &mut TestAppContext) { + let _serial = serial(); + crate::core::config::pin_test_config_dir(); + let root = scratch("decorated"); + if !git(&root, &["init", "--quiet"]) { + return; // no git on this machine + } + std::fs::write(root.join("tracked.rs"), "one\n").unwrap(); + assert!(git(&root, &["add", "-A"])); + assert!(git(&root, &["commit", "--quiet", "-m", "base"])); + std::fs::write(root.join("tracked.rs"), "one\ntwo\n").unwrap(); + std::fs::write(root.join("loose.rs"), "new\n").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/deep.rs"), "new\n").unwrap(); + + let (app, mut vcx, _pane) = files_panel_on(cx, &root); + let decor: Decorations = vec![(root.clone(), scm_index(&app, &mut vcx, &root))]; + let deco = |name: &str, is_dir: bool| { + row_decoration( + &decor, + &TreeEntry { + name: name.to_string(), + path: root.join(name), + is_dir, + ignored: false, + }, + ) + }; + + assert_eq!(deco("tracked.rs", false).letter, "M"); + assert_eq!(deco("loose.rs", false).letter, "?"); + assert_eq!( + deco("src", true).tint, + Some(DecoStatus::Modified), + "the collapsed folder says there is work under it" + ); + assert_eq!( + deco("src", true).letter, + "", + "without pretending to be a file" + ); + + settle(&app, &mut vcx, &root); + assert_eq!(draws_while_idle(&mut vcx), 0); + let _ = std::fs::remove_dir_all(&root); + } + #[gpui::test] fn a_settled_files_panel_on_an_empty_directory_reaches_render_idle(cx: &mut TestAppContext) { let _serial = serial(); diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 865f0268..7bd32be6 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind}; use crate::terminal::view::TerminalView; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::{META, TEXT, TEXT_MONO}; @@ -61,6 +61,10 @@ impl Tty7App { .border_1() .border_color(theme.danger.opacity(0.4)) .shadow_md() + // Off the right panel's ramp on purpose: this bar floats over the + // terminal, not inside the panel, and it is sized against the + // terminal's own text. `app.rs` draws it, `render_panel_info` does + // not. .text_xs() .text_color(theme.muted_foreground) .child( @@ -136,15 +140,21 @@ impl Tty7App { ) -> Option { let pane_id = pane_id?; let open = self.loopback_panel.form_pane_id == Some(pane_id); - let add = crate::ui::tab_strip::chrome_tile( + // The section's own affordance, and the same 24px chrome tile the Info + // tab's cwd actions use. It used to be built by hand — a 32px tile + // forced down to 24 and then set `.xsmall()`, which quietly overrode + // the 13px the icon asked for with the button size's own 12, so the + // glyph never was the size the code claimed. `chrome_tile_sized` + // derives it instead: `TILE_GLYPH_SM / BUTTON_ICON_SCALE` of the button + // size, the same pair every other 24px tile in the panel is on. + let add = crate::ui::tab_strip::chrome_tile_sized( Button::new(("ssh-forward-add-toggle", pane_id)) - .icon(Icon::empty().path("icons/plus.svg").size(px(13.))), + .icon(Icon::empty().path("icons/plus.svg")), + TILE_SIZE_SM, + TILE_GLYPH_SM, open, cx, ) - .xsmall() - .w(px(24.)) - .h(px(24.)) .rounded_md() .tooltip(if open { t(L10nKey::Cancel) diff --git a/src/ui/host_ops.rs b/src/ui/host_ops.rs index a63255d4..4cf22478 100644 --- a/src/ui/host_ops.rs +++ b/src/ui/host_ops.rs @@ -259,6 +259,14 @@ impl InFlight { self.stale.extend(self.in_flight.iter().cloned()); } + /// Drop every key the predicate rejects — bookkeeping for work that will + /// never land (a host cleared away under an in-flight job). If the job + /// does land after all, its `finish` is a no-op rather than a poison. + pub fn retain(&mut self, keep: impl Fn(&K) -> bool) { + self.in_flight.retain(|k| keep(k)); + self.stale.retain(|k| keep(k)); + } + pub fn finish(&mut self, key: &K) -> bool { self.in_flight.remove(key); !self.stale.remove(key) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 7e654576..b9751096 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -885,7 +885,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorFileTooLarge => "\"{path}\" is too large for the editor ({size} MB)", L10nKey::EditorBinaryFile => "\"{path}\" looks like a binary file", L10nKey::PanelInfoTitle => "Info", - L10nKey::PanelChangesTitle => "Changes", + L10nKey::PanelChangesTitle => "Source Control", + L10nKey::PanelScmTitle => "Source Control", L10nKey::PanelFilesTitle => "Files", L10nKey::PanelNoSession => "No active session.", L10nKey::PanelNoSessionHint => { @@ -913,6 +914,95 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelAgentDone => "done", L10nKey::PanelRevealInFinder => "Reveal in Finder", L10nKey::PanelOpenFolder => "Open Folder", + L10nKey::ScmGroupMerge => "Merge Changes", + L10nKey::ScmGroupStaged => "Staged Changes", + L10nKey::ScmGroupChanges => "Changes", + L10nKey::ScmGroupUntracked => "Untracked", + L10nKey::ScmCommitPlaceholder => "Say what changed…", + L10nKey::ScmCommitButton => "Commit", + L10nKey::ScmCommitAllButton => "Commit All", + L10nKey::ScmCommitAmendButton => "Commit (Amend)", + L10nKey::ScmCommitAndPush => "Commit & Push", + L10nKey::ScmCommitAndSync => "Commit & Sync", + L10nKey::ScmAmendLastCommit => "Amend Last Commit", + L10nKey::ScmCommitStaged => "Commit Staged", + L10nKey::ScmStashAll => "Stash All", + L10nKey::ScmNothingToCommit => "Nothing to commit", + L10nKey::ScmNetworkBusy => "Another network operation is still running for this repository", + L10nKey::ScmCommitNeedsMessage => "Write a commit message first", + L10nKey::ScmDetailFilesFailed => "The file list could not be read", + L10nKey::ScmTimeNow => "now", + L10nKey::ScmTimeMinutes => "{n}m", + L10nKey::ScmTimeHours => "{n}h", + L10nKey::ScmTimeDays => "{n}d", + L10nKey::ScmTimeMonths => "{n}mo", + L10nKey::ScmTimeYears => "{n}y", + L10nKey::ScmResetHardConfirm => { + "Reset the branch to this commit? Commits after it fall off the branch, \ + and uncommitted changes are discarded." + } + L10nKey::ScmReset => "Reset", + L10nKey::ScmChipStaged => "STAGED", + L10nKey::ScmStage => "Stage Changes", + L10nKey::ScmStageAll => "Stage All Changes", + L10nKey::ScmUnstage => "Unstage Changes", + L10nKey::ScmUnstageAll => "Unstage All Changes", + L10nKey::ScmDiscard => "Discard Changes", + L10nKey::ScmDiscardAll => "Discard All Changes", + L10nKey::ScmDiscardConfirm => "Discard changes to {path}? This cannot be undone.", + L10nKey::ScmOpenConflict => "Resolve Conflict", + L10nKey::ScmMarkResolved => "Mark as Resolved", + L10nKey::ScmUnrepresentablePath => { + "This path is not valid UTF-8, so git cannot be asked about it — read only." + } + L10nKey::ScmPublishBranch => "Publish Branch", + L10nKey::ScmDetached => "detached", + L10nKey::ScmAmendBadge => "amend", + L10nKey::ScmSync => "Sync Changes", + L10nKey::ScmPush => "Push", + L10nKey::ScmPull => "Pull", + L10nKey::ScmFetch => "Fetch", + L10nKey::ScmCheckoutBranch => "Checkout to…", + L10nKey::ScmCreateBranch => "Create Branch…", + L10nKey::ScmSearchBranches => "Search Branches…", + L10nKey::ScmStashAndSwitch => "Stash & Switch", + L10nKey::ScmGraphTitle => "History", + L10nKey::ScmGraphLoadMore => "Load more", + L10nKey::ScmGraphFilterPlaceholder => "Filter commits…", + L10nKey::ScmGraphAllBranches => "All Branches", + L10nKey::ScmGraphEmpty => "No commits yet", + L10nKey::ScmGraphCurrentBranch => "Current Branch", + L10nKey::ScmCheckoutCommit => "Checkout Commit", + L10nKey::ScmCreateBranchHere => "Create Branch Here…", + L10nKey::ScmResetSoft => "Reset (Soft)", + L10nKey::ScmResetMixed => "Reset (Mixed)", + L10nKey::ScmResetHard => "Reset (Hard)", + L10nKey::ScmCommitDetailTitle => "Commit", + L10nKey::ScmCopyCommitSha => "Copy Commit SHA", + L10nKey::ScmCherryPick => "Cherry Pick", + L10nKey::ScmRevertCommit => "Revert Commit", + 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 => { + "Discard every change in this repository? This cannot be undone." + } + L10nKey::ScmAmendConfirm => { + "Amend the last commit? It will be replaced by a new one, so anyone who already has it has to reconcile." + } + L10nKey::ScmOpMerge => "merging", + L10nKey::ScmOpRebase => "rebasing", + L10nKey::ScmOpCherryPick => "cherry-picking", + L10nKey::ScmOpRevert => "reverting", + L10nKey::ScmOpBisect => "bisecting", + L10nKey::ScmOpAm => "applying", + L10nKey::ScmSwitchRepository => "Switch Repository", L10nKey::WindowStop => "Stop", L10nKey::WindowDelete => "Delete", L10nKey::WindowThisWorkspace => "this workspace", @@ -960,6 +1050,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::DiffBudget => "tty7's budget", L10nKey::DiffPerFileCap => "the per-file cap", L10nKey::DiffUntrackedSummary => "{count} untracked", + L10nKey::DiffViewSplit => "Side by Side", + L10nKey::DiffViewUnified => "Unified", L10nKey::PendingConnecting => "Connecting to {machine}…", L10nKey::PendingUnreachable => "Could not reach {machine}", L10nKey::WorktreePromptNeedsName => "The worktree needs a name", @@ -1130,6 +1222,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdGroupTabsPanes => "Tabs & Panes", L10nKey::CmdGroupWorkspaces => "Workspaces", L10nKey::CmdGroupView => "View", + L10nKey::CmdGroupGit => "Git", L10nKey::CmdGroupTerminal => "Terminal", L10nKey::CmdGroupSsh => "SSH", L10nKey::CmdGroupAgents => "Agents", @@ -1185,6 +1278,22 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdChangeTheme => "Change Theme…", L10nKey::CmdResetFontSize => "Reset Font Size", L10nKey::CmdEnterFullScreen => "Enter Full Screen", + L10nKey::CmdToggleDiffViewMode => "Toggle Unified / Side-by-Side Diff", + L10nKey::CmdGitCommit => "Git: Commit", + L10nKey::CmdGitStageAll => "Git: Stage All Changes", + L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes", + L10nKey::CmdGitDiscardAll => "Git: Discard All Changes", + L10nKey::CmdGitDiscardAllSubtitle => { + "Throws away every uncommitted change in the working tree." + } + L10nKey::CmdGitCheckoutTo => "Git: Checkout to…", + L10nKey::CmdGitCreateBranch => "Git: Create Branch…", + L10nKey::CmdGitSync => "Git: Sync", + L10nKey::CmdGitSyncSubtitle => "Pull, then push.", + L10nKey::CmdGitPush => "Git: Push", + L10nKey::CmdGitPull => "Git: Pull", + L10nKey::CmdGitFetch => "Git: Fetch", + L10nKey::CmdGitToggleGraph => "Git: Toggle Commit History", L10nKey::CmdClearScrollback => "Clear Scrollback", L10nKey::CmdFindInTerminal => "Find in Terminal…", L10nKey::CmdFindNext => "Find Next", @@ -1385,7 +1494,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelMoreChangedFiles => { "… and {count} more changed files — run git diff to see them." } - L10nKey::PanelUntracked => "{count} untracked", + L10nKey::ScmFilesChanged => "{count} files changed", + L10nKey::ScmStagedFileCount => "{count} files staged", L10nKey::AppMenuAbout => "About tty7", L10nKey::AppMenuCheckForUpdates => "Check for Updates…", L10nKey::AppMenuSettings => "Settings…", @@ -1465,6 +1575,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SidebarScratchGroup => "Scratch", L10nKey::TabContextCloseTab => "Close Tab", L10nKey::TabContextCloseTabsBelow => "Close Tabs Below", + L10nKey::AppAgentHooksOpFailed => "Failed: {error}", + L10nKey::AppMenuEnterFullscreen => "Enter Full Screen", + L10nKey::HomeTimeOverWeekAgo => "over a week ago", + L10nKey::Search => "Search", + L10nKey::SettingsDaemonStaleRestart => "Restart Service", + L10nKey::SettingsNoneLower => "none", + L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool", L10nKey::TabContextMarkUnread => "Mark as Unread", } } @@ -1500,9 +1617,12 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::AppTabsNotRestored, "other") => { "{count} tabs from last time could not be reopened" } - (L10nKey::PanelUntracked, "zero") => "0 untracked", - (L10nKey::PanelUntracked, "one") => "1 untracked", - (L10nKey::PanelUntracked, "other") => "{count} untracked", + (L10nKey::ScmFilesChanged, "zero") => "No files changed", + (L10nKey::ScmFilesChanged, "one") => "1 file changed", + (L10nKey::ScmFilesChanged, "other") => "{count} files changed", + (L10nKey::ScmStagedFileCount, "zero") => "No staged changes", + (L10nKey::ScmStagedFileCount, "one") => "1 file staged", + (L10nKey::ScmStagedFileCount, "other") => "{count} files staged", (L10nKey::PanelMoreChangedFiles, "zero") => { "… and 0 more changed files — run git diff to see them." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index ad9b2d1b..3ed1c505 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -927,7 +927,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileTooLarge => "「{path}」はエディタで開くには大きすぎます({size} MB)", L10nKey::EditorBinaryFile => "「{path}」はバイナリファイルのようです", L10nKey::PanelInfoTitle => "情報", - L10nKey::PanelChangesTitle => "変更", + L10nKey::PanelChangesTitle => "ソース管理", + L10nKey::PanelScmTitle => "ソース管理", L10nKey::PanelFilesTitle => "ファイル", L10nKey::PanelNoSession => "アクティブなセッションがありません", L10nKey::PanelNoSessionHint => { @@ -959,6 +960,98 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelAgentDone => "完了", L10nKey::PanelRevealInFinder => "Finder で表示", L10nKey::PanelOpenFolder => "フォルダを開く", + L10nKey::ScmGroupMerge => "マージの競合", + L10nKey::ScmGroupStaged => "ステージされた変更", + L10nKey::ScmGroupChanges => "変更", + L10nKey::ScmGroupUntracked => "未追跡", + L10nKey::ScmCommitPlaceholder => "何を変えたか書いてみましょう…", + L10nKey::ScmCommitButton => "コミット", + L10nKey::ScmCommitAllButton => "すべてコミット", + L10nKey::ScmCommitAmendButton => "コミット(修正)", + L10nKey::ScmCommitAndPush => "コミットしてプッシュ", + L10nKey::ScmCommitAndSync => "コミットして同期", + L10nKey::ScmAmendLastCommit => "直前のコミットを修正", + L10nKey::ScmCommitStaged => "ステージ済みをコミット", + L10nKey::ScmStashAll => "すべてスタッシュ", + L10nKey::ScmNothingToCommit => "コミットするものがありません", + L10nKey::ScmNetworkBusy => "このリポジトリでは別のネットワーク操作が実行中です", + L10nKey::ScmCommitNeedsMessage => "先にコミットメッセージを入力してください", + L10nKey::ScmDetailFilesFailed => "ファイル一覧を読み込めませんでした", + L10nKey::ScmTimeNow => "今", + L10nKey::ScmTimeMinutes => "{n}分", + // 「{n}時」は時刻に読めるので「時間」のまま。 + L10nKey::ScmTimeHours => "{n}時間", + L10nKey::ScmTimeDays => "{n}日", + L10nKey::ScmTimeMonths => "{n}か月", + L10nKey::ScmTimeYears => "{n}年", + L10nKey::ScmResetHardConfirm => { + "ブランチをこのコミットへリセットしますか?それ以降のコミットはブランチから外れ、\ + 未コミットの変更は破棄されます。" + } + L10nKey::ScmReset => "リセット", + L10nKey::ScmChipStaged => "ステージ済み", + L10nKey::ScmStage => "変更をステージ", + L10nKey::ScmStageAll => "すべての変更をステージ", + L10nKey::ScmUnstage => "ステージを取り消す", + L10nKey::ScmUnstageAll => "すべてのステージを取り消す", + L10nKey::ScmDiscard => "変更を破棄", + L10nKey::ScmDiscardAll => "すべての変更を破棄", + L10nKey::ScmDiscardConfirm => "{path} の変更を破棄しますか?元に戻せません。", + L10nKey::ScmOpenConflict => "競合を解決", + L10nKey::ScmMarkResolved => "解決済みにする", + L10nKey::ScmUnrepresentablePath => { + "このパスは正しい UTF-8 ではないため git に渡せません — 閲覧のみです。" + } + L10nKey::ScmPublishBranch => "ブランチを公開", + L10nKey::ScmDetached => "デタッチ", + L10nKey::ScmAmendBadge => "修正", + L10nKey::ScmSync => "変更を同期", + L10nKey::ScmPush => "プッシュ", + L10nKey::ScmPull => "プル", + L10nKey::ScmFetch => "フェッチ", + L10nKey::ScmCheckoutBranch => "チェックアウト…", + L10nKey::ScmCreateBranch => "ブランチを作成…", + L10nKey::ScmSearchBranches => "ブランチを検索…", + L10nKey::ScmStashAndSwitch => "スタッシュして切り替え", + L10nKey::ScmGraphTitle => "履歴", + L10nKey::ScmGraphLoadMore => "さらに読み込む", + L10nKey::ScmGraphFilterPlaceholder => "コミットを絞り込む…", + L10nKey::ScmGraphAllBranches => "すべてのブランチ", + L10nKey::ScmGraphEmpty => "まだコミットがありません", + L10nKey::ScmGraphCurrentBranch => "現在のブランチ", + L10nKey::ScmCheckoutCommit => "このコミットをチェックアウト", + L10nKey::ScmCreateBranchHere => "ここにブランチを作成…", + L10nKey::ScmResetSoft => "リセット(ソフト)", + L10nKey::ScmResetMixed => "リセット(ミックス)", + L10nKey::ScmResetHard => "リセット(ハード)", + L10nKey::ScmCommitDetailTitle => "コミット", + L10nKey::ScmCopyCommitSha => "コミット SHA をコピー", + L10nKey::ScmCherryPick => "チェリーピック", + L10nKey::ScmRevertCommit => "コミットを取り消す", + L10nKey::ScmResetToCommit => "このコミットにリセット", + L10nKey::ScmRefresh => "更新", + L10nKey::ScmBackToChanges => "戻る", + L10nKey::ScmCommitParents => "親コミット", + L10nKey::ScmShowMore => "続きを表示", + L10nKey::ScmShowLess => "折りたたむ", + L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。", + L10nKey::ScmTooManyChanges => { + "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" + } + L10nKey::ScmOpenChanges => "変更を開く", + L10nKey::ScmDiscardAllConfirm => { + "このリポジトリのすべての変更を破棄しますか?元に戻せません。" + } + L10nKey::ScmAmendConfirm => { + "直前のコミットを修正しますか?新しいコミットに置き換わるため、すでに取得した人は対応が必要になります。" + } + L10nKey::ScmOpMerge => "マージ中", + L10nKey::ScmOpRebase => "リベース中", + L10nKey::ScmOpCherryPick => "チェリーピック中", + L10nKey::ScmOpRevert => "リバート中", + L10nKey::ScmOpBisect => "二分探索中", + L10nKey::ScmOpAm => "パッチ適用中", + L10nKey::ScmSwitchRepository => "リポジトリを切り替え", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "削除", L10nKey::WindowThisWorkspace => "このワークスペース", @@ -1004,6 +1097,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::DiffBudget => "tty7 の予算", L10nKey::DiffPerFileCap => "ファイルごとの上限", L10nKey::DiffUntrackedSummary => "未追跡 {count}", + L10nKey::DiffViewSplit => "左右分割", + L10nKey::DiffViewUnified => "統合", L10nKey::PendingConnecting => "{machine} に接続中…", L10nKey::PendingUnreachable => "{machine} に到達できませんでした", L10nKey::WorktreePromptNeedsName => "ワークツリーには名前が必要です", @@ -1161,6 +1256,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdGroupTabsPanes => "タブとペイン", L10nKey::CmdGroupWorkspaces => "ワークスペース", L10nKey::CmdGroupView => "表示", + L10nKey::CmdGroupGit => "Git", L10nKey::CmdGroupTerminal => "ターミナル", L10nKey::CmdGroupSsh => "SSH", L10nKey::CmdGroupAgents => "エージェント", @@ -1216,6 +1312,20 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdChangeTheme => "テーマを変更…", L10nKey::CmdResetFontSize => "フォントサイズをリセット", L10nKey::CmdEnterFullScreen => "全画面表示", + L10nKey::CmdToggleDiffViewMode => "統合 / 左右分割の差分表示を切り替え", + L10nKey::CmdGitCommit => "Git: コミット", + L10nKey::CmdGitStageAll => "Git: すべての変更をステージ", + L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す", + L10nKey::CmdGitDiscardAll => "Git: すべての変更を破棄", + L10nKey::CmdGitDiscardAllSubtitle => "ワークツリーの未コミットの変更をすべて捨てます。", + L10nKey::CmdGitCheckoutTo => "Git: チェックアウト…", + L10nKey::CmdGitCreateBranch => "Git: ブランチを作成…", + L10nKey::CmdGitSync => "Git: 同期", + L10nKey::CmdGitSyncSubtitle => "プルしてからプッシュします。", + L10nKey::CmdGitPush => "Git: プッシュ", + L10nKey::CmdGitPull => "Git: プル", + L10nKey::CmdGitFetch => "Git: フェッチ", + L10nKey::CmdGitToggleGraph => "Git: コミット履歴の表示切替", L10nKey::CmdClearScrollback => "スクロールバックをクリア", L10nKey::CmdFindInTerminal => "ターミナル内を検索…", L10nKey::CmdFindNext => "次を検索", @@ -1431,7 +1541,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelMoreChangedFiles => { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } - L10nKey::PanelUntracked => "未追跡 {count}", + L10nKey::ScmFilesChanged => "{count} 個のファイルが変更されました", + L10nKey::ScmStagedFileCount => "{count} 個のファイルがステージされました", L10nKey::AppMenuAbout => "tty7 について", L10nKey::AppMenuCheckForUpdates => "アップデートを確認…", L10nKey::AppMenuSettings => "設定…", @@ -1511,6 +1622,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SidebarScratchGroup => "スクラッチ", L10nKey::TabContextCloseTab => "タブを閉じる", L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる", + L10nKey::AppAgentHooksOpFailed => "失敗: {error}", + L10nKey::AppMenuEnterFullscreen => "全画面表示", + L10nKey::HomeTimeOverWeekAgo => "1 週間以上前", + L10nKey::Search => "検索", + L10nKey::SettingsDaemonStaleRestart => "サービスを再起動", + L10nKey::SettingsNoneLower => "なし", + L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール", L10nKey::TabContextMarkUnread => "未読としてマーク", }) } @@ -1542,9 +1660,12 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat } (L10nKey::AppTabsNotRestored, "one") => "前回のタブ 1 個を開き直せませんでした", (L10nKey::AppTabsNotRestored, "other") => "前回のタブ {count} 個を開き直せませんでした", - (L10nKey::PanelUntracked, "zero") => "未追跡 0", - (L10nKey::PanelUntracked, "one") => "未追跡 1", - (L10nKey::PanelUntracked, "other") => "未追跡 {count}", + (L10nKey::ScmFilesChanged, "zero") => "変更されたファイルはありません", + (L10nKey::ScmFilesChanged, "one") => "1 個のファイルが変更されました", + (L10nKey::ScmFilesChanged, "other") => "{count} 個のファイルが変更されました", + (L10nKey::ScmStagedFileCount, "zero") => "ステージされた変更はありません", + (L10nKey::ScmStagedFileCount, "one") => "1 個のファイルがステージされました", + (L10nKey::ScmStagedFileCount, "other") => "{count} 個のファイルがステージされました", (L10nKey::PanelMoreChangedFiles, "zero") => { "… さらに変更されたファイル 0 個 — 表示するには `git diff` を実行してください" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index edc62a9b..1a448775 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -76,6 +76,7 @@ l10n_keys! { FilterHosts, SearchCommandsOrHost, SearchTheme, + Search, SearchWorkspacesAndMachines, SearchFonts, SearchFind, @@ -255,6 +256,7 @@ l10n_keys! { SettingsJumpHost, SettingsJumpHostDesc, SettingsNoneSummary, + SettingsNoneLower, SettingsPortForwarding, SettingsRulesOpenedWithConnection, SettingsAddRule, @@ -482,6 +484,7 @@ l10n_keys! { SettingsUpdateChannelNightly, SettingsDaemonStale, SettingsDaemonStaleDesc, + SettingsDaemonStaleRestart, UpdateDialogTitle, UpdateDialogDetail, UpdateDialogDetailWindows, @@ -534,6 +537,7 @@ l10n_keys! { SettingsSearchClaudeCodeKeywords, SettingsSearchCodexKeywords, SettingsSearchCommandLineToolKeywords, + SettingsSearchCommandLineToolTitle, SettingsSearchCopilotCliKeywords, SettingsSearchCopyOnSelectKeywords, SettingsSearchCursorBlinkKeywords, @@ -682,6 +686,7 @@ l10n_keys! { EditorBinaryFile, PanelInfoTitle, PanelChangesTitle, + PanelScmTitle, PanelFilesTitle, PanelNoSession, PanelNoSessionHint, @@ -693,7 +698,6 @@ l10n_keys! { PanelNoChanges, PanelNoChangesHint, PanelMoreChangedFiles, - PanelUntracked, PanelSessionSubtitle, PanelProcessesSubtitle, PanelPortsSubtitle, @@ -709,6 +713,88 @@ l10n_keys! { PanelAgentDone, PanelRevealInFinder, PanelOpenFolder, + ScmGroupMerge, + ScmGroupStaged, + ScmGroupChanges, + ScmGroupUntracked, + ScmCommitPlaceholder, + ScmCommitButton, + ScmCommitAllButton, + ScmCommitAmendButton, + ScmCommitAndPush, + ScmCommitAndSync, + ScmAmendLastCommit, + ScmCommitStaged, + ScmStashAll, + ScmNothingToCommit, + ScmNetworkBusy, + ScmCommitNeedsMessage, + ScmDetailFilesFailed, + ScmTimeNow, + ScmTimeMinutes, + ScmTimeHours, + ScmTimeDays, + ScmTimeMonths, + ScmTimeYears, + ScmResetHardConfirm, + ScmReset, + ScmChipStaged, + ScmStage, + ScmStageAll, + ScmUnstage, + ScmUnstageAll, + ScmDiscard, + ScmDiscardAll, + ScmDiscardConfirm, + ScmOpenConflict, + ScmMarkResolved, + ScmUnrepresentablePath, + ScmPublishBranch, + ScmDetached, + ScmAmendBadge, + ScmSync, + ScmPush, + ScmPull, + ScmFetch, + ScmCheckoutBranch, + ScmCreateBranch, + ScmSearchBranches, + ScmStashAndSwitch, + ScmGraphTitle, + ScmGraphLoadMore, + ScmGraphFilterPlaceholder, + ScmGraphAllBranches, + ScmGraphEmpty, + ScmGraphCurrentBranch, + ScmCheckoutCommit, + ScmCreateBranchHere, + ScmResetSoft, + ScmResetMixed, + ScmResetHard, + ScmCommitDetailTitle, + ScmCopyCommitSha, + ScmCherryPick, + ScmRevertCommit, + ScmResetToCommit, + ScmRefresh, + ScmBackToChanges, + ScmCommitParents, + ScmShowMore, + ScmShowLess, + ScmCommitNotFound, + ScmTooManyChanges, + ScmOpenChanges, + ScmDiscardAllConfirm, + ScmAmendConfirm, + ScmOpMerge, + ScmOpRebase, + ScmOpCherryPick, + ScmOpRevert, + ScmOpBisect, + ScmOpAm, + ScmSwitchRepository, + ScmFilesChanged, + ScmStagedFileCount, WindowStop, WindowDelete, WindowThisWorkspace, @@ -736,6 +822,8 @@ l10n_keys! { DiffBudget, DiffPerFileCap, DiffUntrackedSummary, + DiffViewSplit, + DiffViewUnified, PendingConnecting, PendingUnreachable, WorktreePromptNeedsName, @@ -752,6 +840,7 @@ l10n_keys! { HomeTimeHoursAgo, HomeTimeYesterday, HomeTimeDaysAgo, + HomeTimeOverWeekAgo, HomeTimeWeeksAgo, HomeTimeMonthsAgo, HomeTimeOverYearAgo, @@ -808,6 +897,7 @@ l10n_keys! { AppMenuFocusPreviousPane, AppMenuZoomPane, AppMenuClearScrollback, + AppMenuEnterFullscreen, AppMenuDocumentation, AppMenuKeyboardShortcuts, AppMenuJoinDiscord, @@ -927,6 +1017,7 @@ l10n_keys! { CmdGroupTabsPanes, CmdGroupWorkspaces, CmdGroupView, + CmdGroupGit, CmdGroupTerminal, CmdGroupSsh, CmdGroupAgents, @@ -982,6 +1073,20 @@ l10n_keys! { CmdChangeTheme, CmdResetFontSize, CmdEnterFullScreen, + CmdToggleDiffViewMode, + CmdGitCommit, + CmdGitStageAll, + CmdGitUnstageAll, + CmdGitDiscardAll, + CmdGitDiscardAllSubtitle, + CmdGitCheckoutTo, + CmdGitCreateBranch, + CmdGitSync, + CmdGitSyncSubtitle, + CmdGitPush, + CmdGitPull, + CmdGitFetch, + CmdGitToggleGraph, CmdClearScrollback, CmdFindInTerminal, CmdFindNext, @@ -1082,6 +1187,7 @@ l10n_keys! { AppAgentHooksNoHomeDir, AppAgentHooksOffline, AppAgentHooksHomeDirUnresolved, + AppAgentHooksOpFailed, AppAgentHooksInstalled, AppAgentHooksInstalledEnableCodexThere, AppAgentHooksInstalledCodexEnableFailed, @@ -1126,6 +1232,25 @@ l10n_keys! { SettingsPerPaneHistoryDescription, } +/// The source control strings that are translated but not yet displayed. +/// +/// They all ship in one go so the three language files are edited once for the +/// whole feature instead of once per step, and so the wording can be reviewed +/// as a set rather than a string at a time. Naming them here is what keeps +/// `dead_code` reporting on the rest of the enum: without it every unused key +/// is folded into one warning and a genuinely stale key hides in the crowd. +/// +/// **Delete a key from this list as soon as something renders it.** +#[allow(dead_code)] +const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ + L10nKey::ScmCheckoutBranch, + L10nKey::ScmCommitDetailTitle, + L10nKey::ScmCommitStaged, + L10nKey::ScmResetToCommit, + L10nKey::ScmSearchBranches, + L10nKey::ScmStashAndSwitch, +]; + pub fn set_locale(gui_language: &str) { let index = SUPPORTED_LANGUAGES .iter() @@ -1258,6 +1383,7 @@ mod tests { const KEPT_IN_ENGLISH: &[L10nKey] = &[ // Protocol, format and command names no locale translates. L10nKey::CmdGroupSsh, + L10nKey::CmdGroupGit, L10nKey::SettingsNavSsh, L10nKey::ForwardSocksLabel, L10nKey::RemoteInstallShaLabel, @@ -1286,6 +1412,535 @@ mod tests { L10nKey::SettingsBackdropMicaAlt, L10nKey::SettingsBackdropAcrylic, // A language is named in its own language on every locale's list. + L10nKey::SettingsSearchAboutKeywords, + L10nKey::SettingsSearchAppHttpProxyKeywords, + L10nKey::SettingsSearchAnsiColorsKeywords, + L10nKey::SettingsSearchArgumentsKeywords, + L10nKey::SettingsSearchBlurKeywords, + L10nKey::SettingsSearchBoldFontKeywords, + L10nKey::SettingsSearchClaudeCodeKeywords, + L10nKey::SettingsSearchCodexKeywords, + L10nKey::SettingsSearchCommandLineToolKeywords, + L10nKey::SettingsSearchCommandLineToolTitle, + L10nKey::SettingsSearchCopilotCliKeywords, + L10nKey::SettingsSearchCopyOnSelectKeywords, + L10nKey::SettingsSearchCursorBlinkKeywords, + L10nKey::SettingsSearchCursorShapeKeywords, + L10nKey::SettingsSearchCustomThemesKeywords, + L10nKey::SettingsSearchDetectUrlsKeywords, + L10nKey::SettingsSearchDiffPreviewFromCountsKeywords, + L10nKey::SettingsSearchDimInactivePanesKeywords, + L10nKey::SettingsSearchFocusFollowsMouseKeywords, + L10nKey::SettingsSearchFontFamilyKeywords, + L10nKey::SettingsSearchFontLigaturesKeywords, + L10nKey::SettingsSearchFontSizeKeywords, + L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords, + L10nKey::SettingsSearchGrokBuildKeywords, + L10nKey::SettingsSearchHideMouseWhileTypingKeywords, + L10nKey::SettingsSearchHistorySearchKeywords, + L10nKey::SettingsSearchHostsKeywords, + L10nKey::SettingsSearchHowShellsWorkKeywords, + L10nKey::SettingsSearchHowShellsWorkTitle, + L10nKey::SettingsSearchItalicFontKeywords, + L10nKey::SettingsSearchKeybindingsKeywords, + L10nKey::SettingsSearchKeybindingsTitle, + L10nKey::SettingsSearchLineHeightKeywords, + L10nKey::SettingsSearchNewTabPositionKeywords, + L10nKey::SettingsSearchNotifyOnCommandFinishKeywords, + L10nKey::SettingsSearchNotifyThresholdKeywords, + L10nKey::SettingsSearchOhMyPiKeywords, + L10nKey::SettingsSearchOpacityKeywords, + L10nKey::SettingsSearchOpenFilesWithKeywords, + L10nKey::SettingsSearchOpencodeKeywords, + L10nKey::SettingsSearchOptionAsMetaKeywords, + L10nKey::SettingsSearchPiKeywords, + L10nKey::SettingsSearchPortForwardingKeywords, + L10nKey::SettingsSearchProgramKeywords, + L10nKey::SettingsSearchRememberWindowSizeKeywords, + L10nKey::SettingsSearchReportMouseToAppsKeywords, + L10nKey::SettingsSearchRestoreLastLayoutKeywords, + L10nKey::SettingsSearchScrollSpeedKeywords, + L10nKey::SettingsSearchScrollbackKeywords, + L10nKey::SettingsSearchShowTrayIconKeywords, + L10nKey::SettingsSearchSidebarGroupingKeywords, + L10nKey::SettingsSearchSmartSelectionKeywords, + L10nKey::SettingsSearchStartInKeywords, + L10nKey::SettingsSearchSyncWithSystemKeywords, + L10nKey::SettingsSearchTabBarPositionKeywords, + L10nKey::SettingsSearchTabCompletionKeywords, + L10nKey::SettingsSearchTerminalBellKeywords, + L10nKey::SettingsSearchThemeKeywords, + L10nKey::SettingsSearchTrimTrailingSpacesKeywords, + L10nKey::SettingsSearchVerifyHostKeysKeywords, + L10nKey::SettingsSearchWarnBeforeClosingKeywords, + L10nKey::SettingsSearchStartupWindowKeywords, + L10nKey::SwitcherNoMatch, + L10nKey::AddSshHost, + L10nKey::ClickForNewWindow, + L10nKey::RestartServer, + L10nKey::OtherMachines, + L10nKey::Ok, + L10nKey::SftpNoTransfers, + L10nKey::SftpPanelTitleFiles, + L10nKey::SftpTooltipRefresh, + L10nKey::SftpTooltipMore, + L10nKey::SftpMenuNewFolder, + L10nKey::SftpMenuNewFile, + L10nKey::SftpMenuUpload, + L10nKey::SftpMenuGotoShellCwd, + L10nKey::SftpMenuHideTransferHistory, + L10nKey::SftpMenuTransferHistory, + L10nKey::SftpEditNewFolder, + L10nKey::SftpEditNewFile, + L10nKey::SftpEditRename, + L10nKey::SftpEditPermissions, + L10nKey::SftpLoading, + L10nKey::SftpEmptyDirectory, + L10nKey::SftpContextOpen, + L10nKey::SftpContextFollowSymlink, + L10nKey::SftpContextRename, + L10nKey::SftpContextChmod, + L10nKey::SftpTransferSummaryRunning, + L10nKey::SftpTransferSummaryFailed, + L10nKey::SftpTransferSummaryIdle, + L10nKey::SftpTransferProgress, + L10nKey::SftpTransferDone, + L10nKey::SftpTransferCancelled, + L10nKey::SftpTransferError, + L10nKey::SftpImagePasteUploadFailed, + L10nKey::ForwardPanelTitle, + L10nKey::ForwardDisconnected, + L10nKey::ForwardDisconnectedFrom, + L10nKey::ForwardTooltipAdd, + L10nKey::ForwardTooltipRemove, + L10nKey::ForwardLocal, + L10nKey::ForwardRemote, + L10nKey::ForwardDynamic, + L10nKey::ForwardBindLabel, + L10nKey::ForwardToLabel, + L10nKey::ForwardSocksLabel, + L10nKey::ForwardAdd, + L10nKey::FileTreePlaceholderFileName, + L10nKey::FileTreePlaceholderFolderName, + L10nKey::FileTreePlaceholderNewName, + L10nKey::FileTreeDeleteTitle, + L10nKey::FileTreeDeleteFolderBody, + L10nKey::FileTreeDeleteFileBody, + L10nKey::FileTreeDeleteFailed, + L10nKey::FileTreeContextOpen, + L10nKey::FileTreeContextCdHere, + L10nKey::FileTreeContextInsertPath, + L10nKey::FileTreeContextAttachAgent, + L10nKey::FileTreeContextNewFile, + L10nKey::FileTreeContextNewFolder, + L10nKey::FileTreeContextRename, + L10nKey::FileTreeContextCopyPath, + L10nKey::FileTreeContextHideDotfiles, + L10nKey::FileTreeContextShowDotfiles, + L10nKey::SshPromptNewKey, + L10nKey::SshPromptOldKey, + L10nKey::EditorCantOpen, + L10nKey::EditorCantRead, + L10nKey::EditorNotUtf8, + L10nKey::EditorSaveFailed, + L10nKey::EditorUnsavedChanges, + L10nKey::EditorDiscard, + L10nKey::EditorNoFileOpen, + L10nKey::EditorBackToTerminal, + L10nKey::EditorLnCol, + L10nKey::EditorEdit, + L10nKey::EditorPreview, + L10nKey::EditorWrapOn, + L10nKey::EditorWrapOff, + L10nKey::EditorFileTooLarge, + L10nKey::EditorBinaryFile, + L10nKey::PanelInfoTitle, + L10nKey::PanelChangesTitle, + L10nKey::PanelFilesTitle, + L10nKey::PanelNoSession, + L10nKey::PanelNoSessionHint, + L10nKey::PanelNoWorkingDirectory, + L10nKey::PanelNoWorkingDirectoryHint, + L10nKey::PanelLoading, + L10nKey::PanelNotAGitRepo, + L10nKey::PanelNotAGitRepoHint, + L10nKey::PanelNoChanges, + L10nKey::PanelNoChangesHint, + L10nKey::PanelMoreChangedFiles, + L10nKey::PanelSessionSubtitle, + L10nKey::PanelProcessesSubtitle, + L10nKey::PanelPortsSubtitle, + L10nKey::PanelCwd, + L10nKey::PanelShell, + L10nKey::PanelSsh, + L10nKey::PanelBranch, + L10nKey::PanelChangesRow, + L10nKey::PanelAgent, + L10nKey::PanelAgentIdle, + L10nKey::PanelAgentWorking, + L10nKey::PanelAgentWaiting, + L10nKey::PanelAgentDone, + L10nKey::PanelRevealInFinder, + L10nKey::PanelOpenFolder, + L10nKey::WindowStop, + L10nKey::WindowDelete, + L10nKey::WindowThisWorkspace, + L10nKey::WindowConfirmTitle, + L10nKey::WindowStopUnreachable, + L10nKey::WindowDeleteUnreachable, + L10nKey::WindowStopShells, + L10nKey::WindowDeleteShells, + L10nKey::DiffReading, + L10nKey::DiffNotARepo, + L10nKey::DiffReadFailed, + L10nKey::DiffWorkingTreeClean, + L10nKey::DiffCloseTooltip, + L10nKey::DiffChangedFiles, + L10nKey::DiffUntrackedCount, + L10nKey::DiffMoreFiles, + L10nKey::DiffOversizedNotice, + L10nKey::DiffTruncatedPerFile, + L10nKey::DiffTruncatedBudget, + L10nKey::DiffUntrackedHeader, + L10nKey::DiffMoreUntracked, + L10nKey::DiffLines, + L10nKey::DiffChangedLines, + L10nKey::DiffBudgetAndCap, + L10nKey::DiffBudget, + L10nKey::DiffPerFileCap, + L10nKey::DiffUntrackedSummary, + L10nKey::PendingConnecting, + L10nKey::PendingUnreachable, + L10nKey::WorktreePromptNeedsName, + L10nKey::WorktreePromptTitle, + L10nKey::WorktreePromptName, + L10nKey::WorktreePromptBranch, + L10nKey::WorktreePromptBase, + L10nKey::WorktreePromptCreating, + L10nKey::WorktreePromptCreate, + L10nKey::AppNewWorktreeFailed, + L10nKey::HomeTimeJustNow, + L10nKey::HomeTimeMinutesAgo, + L10nKey::HomeTimeHourAgo, + L10nKey::HomeTimeHoursAgo, + L10nKey::HomeTimeYesterday, + L10nKey::HomeTimeDaysAgo, + L10nKey::HomeTimeOverWeekAgo, + L10nKey::HomeReopenNamed, + L10nKey::AppMenuAbout, + L10nKey::AppMenuCheckForUpdates, + L10nKey::AppMenuSettings, + L10nKey::AppMenuServices, + L10nKey::AppMenuHideApp, + L10nKey::AppMenuHideOthers, + L10nKey::AppMenuShowAll, + L10nKey::AppMenuQuit, + L10nKey::AppMenuFile, + L10nKey::AppMenuEdit, + L10nKey::AppMenuView, + L10nKey::AppMenuWindow, + L10nKey::AppMenuHelp, + L10nKey::AppMenuNewTab, + L10nKey::AppMenuNewWorkspace, + L10nKey::AppMenuNewWorktreeTab, + L10nKey::AppMenuSplitRight, + L10nKey::AppMenuSplitLeft, + L10nKey::AppMenuSplitDown, + L10nKey::AppMenuSplitUp, + L10nKey::AppMenuRenameTab, + L10nKey::AppMenuCopyWorkingDirectory, + L10nKey::AppMenuCopySessionId, + L10nKey::AppMenuForkSession, + L10nKey::AppMenuClosePaneTab, + L10nKey::AppMenuCloseOtherTabs, + L10nKey::AppMenuCloseTabsRight, + L10nKey::AppMenuReopenClosedTab, + L10nKey::AppMenuRenameWorkspace, + L10nKey::AppMenuStopWorkspace, + L10nKey::AppMenuDeleteWorkspace, + L10nKey::AppMenuUndo, + L10nKey::AppMenuRedo, + L10nKey::AppMenuCut, + L10nKey::AppMenuCopy, + L10nKey::AppMenuPaste, + L10nKey::AppMenuSelectAll, + L10nKey::AppMenuFind, + L10nKey::AppMenuFindNext, + L10nKey::AppMenuFindPrevious, + L10nKey::AppMenuCommandPalette, + L10nKey::AppMenuIncreaseFontSize, + L10nKey::AppMenuDecreaseFontSize, + L10nKey::AppMenuResetFontSize, + L10nKey::AppMenuLeftSidebar, + L10nKey::AppMenuRightPanel, + L10nKey::AppMenuCodePanel, + L10nKey::AppMenuTabBarPosition, + L10nKey::AppMenuFocusNextPane, + L10nKey::AppMenuFocusPreviousPane, + L10nKey::AppMenuZoomPane, + L10nKey::AppMenuClearScrollback, + L10nKey::AppMenuEnterFullscreen, + L10nKey::AppMenuDocumentation, + L10nKey::AppMenuKeyboardShortcuts, + L10nKey::AppMenuJoinDiscord, + L10nKey::AppMenuReportIssue, + L10nKey::AppMenuRestartServer, + L10nKey::WindowUntitled, + L10nKey::TrayShowTty7, + L10nKey::TrayNotifications, + L10nKey::TrayAgentNeedsInput, + L10nKey::NotifyCommandFinished, + L10nKey::NotifyCommandFinishedWithCommand, + L10nKey::NotifyAgentFinished, + L10nKey::NotifyAgentWaiting, + L10nKey::NotifyTurnFinished, + L10nKey::TabTooltipMore, + L10nKey::TabTooltipShowSidebar, + L10nKey::TabTooltipHideSidebar, + L10nKey::TabTooltipHideDetailPanel, + L10nKey::TabTooltipShowDetailPanel, + L10nKey::TabUnnamedShell, + L10nKey::ShellDefault, + L10nKey::SidebarScratchGroup, + L10nKey::TabContextCloseTab, + L10nKey::TabContextCloseTabsBelow, + L10nKey::TabContextMarkUnread, + L10nKey::RemoteStripDisconnected, + L10nKey::RemoteStripConnecting, + L10nKey::RemoteStripReconnecting, + L10nKey::RemoteStripReconnectingAttempt, + L10nKey::RemoteStripPreempted, + L10nKey::RemoteStripFailed, + L10nKey::RemoteNoticePreempted, + L10nKey::RemoteNoticeDisconnected, + L10nKey::RemoteActionRetryNow, + L10nKey::RemoteActionTakeBack, + L10nKey::RemoteActionConnect, + L10nKey::RemoteActionRetry, + L10nKey::RemoteNoConnectionDetails, + L10nKey::RemoteThisComputer, + L10nKey::RemoteRestartTitle, + L10nKey::RemoteRestartBody, + L10nKey::RemoteReplaceBody, + L10nKey::RemoteRestartFailedTitle, + L10nKey::RemoteRestartFailedBody, + L10nKey::RemoteHostUnreachable, + L10nKey::RemoteInstallTitle, + L10nKey::RemoteInstallDetail, + L10nKey::RemoteInstallPathLabel, + L10nKey::RemoteInstallVersionLabel, + L10nKey::RemoteInstallSizeLabel, + L10nKey::RemoteInstallFromLabel, + L10nKey::RemoteInstallShaLabel, + L10nKey::RemoteInstallSilentUpgrades, + L10nKey::RemoteInstallBytes, + L10nKey::RemoteMismatchTitle, + L10nKey::RemoteMismatchDetail, + L10nKey::RemoteMismatchUnknownBuild, + L10nKey::RemoteMismatchUnknownBuildFromExe, + L10nKey::RemoteMismatchReplaceServer, + L10nKey::RemoteServerOutdated, + L10nKey::RemoteServerTooNew, + L10nKey::RemoteDaemonStartFailed, + L10nKey::RemoteDaemonUnreachable, + L10nKey::RemoteDaemonTooOld, + L10nKey::RemoteProfileMissing, + L10nKey::RemoteAliasMissing, + L10nKey::RemoteWslNoSsh, + L10nKey::RemoteLocalStdioNoSsh, + L10nKey::RemoteHostNotTty7, + L10nKey::RemoteWorkspaceListFailed, + L10nKey::RemoteServerRestartFailed, + L10nKey::RemoteNoRouteToHost, + L10nKey::RemoteMachineTreeUnexpectedReply, + L10nKey::RemoteMismatchVersionFromExe, + L10nKey::AppNoRunningCodingAgent, + L10nKey::SwitcherThisComputer, + L10nKey::SwitcherRestartingServer, + L10nKey::SwitcherDownloadingServerWithTotal, + L10nKey::SwitcherDownloadingServerNoTotal, + L10nKey::SwitcherCopyingServer, + L10nKey::SwitcherThisWindow, + L10nKey::SwitcherOpen, + L10nKey::SwitcherDisconnect, + L10nKey::SwitcherOpenInNewWindow, + L10nKey::SwitcherRename, + L10nKey::SwitcherPickAWorkspace, + L10nKey::SwitcherNoTabs, + L10nKey::SwitcherTabsAfterOpening, + L10nKey::SwitcherTabCount, + L10nKey::SwitcherTabCountOne, + L10nKey::SwitcherActiveTab, + L10nKey::SwitcherHoldToSwitch, + L10nKey::SshPromptPasswordFor, + L10nKey::SshPromptPassphraseFor, + L10nKey::SshPromptTwoFactor, + L10nKey::SshPromptUnknownHost, + L10nKey::SshPromptHostKeyChanged, + L10nKey::SshPromptHostKeyChangedBody, + L10nKey::SshPromptConnect, + L10nKey::SshPromptUnlock, + L10nKey::SshPromptSubmit, + L10nKey::HostOpsError, + L10nKey::TreeWindowOpenedEmpty, + L10nKey::CmdGroupTabsPanes, + L10nKey::CmdGroupWorkspaces, + L10nKey::CmdGroupView, + L10nKey::CmdGroupTerminal, + L10nKey::CmdGroupSsh, + L10nKey::CmdGroupAgents, + L10nKey::CmdGroupApplication, + L10nKey::CmdNewTab, + L10nKey::CmdNewWorktreeTab, + L10nKey::CmdNewWorktreeTabSubtitle, + L10nKey::CmdRenameTab, + L10nKey::CmdSplitRight, + L10nKey::CmdSplitDown, + L10nKey::CmdZoomPane, + L10nKey::CmdNextPane, + L10nKey::CmdPreviousPane, + L10nKey::CmdFocusPaneLeft, + L10nKey::CmdFocusPaneRight, + L10nKey::CmdFocusPaneUp, + L10nKey::CmdFocusPaneDown, + L10nKey::CmdResizePaneLeft, + L10nKey::CmdResizePaneRight, + L10nKey::CmdResizePaneUp, + L10nKey::CmdResizePaneDown, + L10nKey::CmdSwapPaneNext, + L10nKey::CmdSwapPanePrevious, + L10nKey::CmdNextTab, + L10nKey::CmdPreviousTab, + L10nKey::CmdCopyWorkingDirectory, + L10nKey::CmdCopySessionId, + L10nKey::CmdCopySessionIdSubtitle, + L10nKey::CmdForkSession, + L10nKey::CmdForkSessionSubtitle, + L10nKey::CmdMarkTabAsUnread, + L10nKey::CmdClosePaneTab, + L10nKey::CmdCloseOtherTabs, + L10nKey::CmdCloseTabsToTheRight, + L10nKey::CmdReopenClosedTab, + L10nKey::CmdNewWorkspace, + L10nKey::CmdSwitchWorkspace, + L10nKey::CmdRenameWorkspace, + L10nKey::CmdStopWorkspace, + L10nKey::CmdStopWorkspaceSubtitle, + L10nKey::CmdDeleteWorkspace, + L10nKey::CmdDeleteWorkspaceSubtitle, + L10nKey::CmdShowLeftSidebar, + L10nKey::CmdHideLeftSidebar, + L10nKey::CmdHideRightPanel, + L10nKey::CmdShowRightPanel, + L10nKey::CmdShowCodePanel, + L10nKey::CmdTabBarMoveToTop, + L10nKey::CmdTabBarMoveToLeftSidebar, + L10nKey::CmdRightPanelInfo, + L10nKey::CmdRightPanelChanges, + L10nKey::CmdRightPanelFiles, + L10nKey::CmdChangeTheme, + L10nKey::CmdResetFontSize, + L10nKey::CmdEnterFullScreen, + L10nKey::CmdClearScrollback, + L10nKey::CmdFindInTerminal, + L10nKey::CmdFindNext, + L10nKey::CmdFindPrevious, + L10nKey::CmdCopy, + L10nKey::CmdCut, + L10nKey::CmdPaste, + L10nKey::CmdSelectAll, + L10nKey::CmdSshAddConnection, + L10nKey::CmdSshManageProfiles, + L10nKey::CmdSshReconnect, + L10nKey::CmdSshRemoteFiles, + L10nKey::CmdSshPortForwarding, + L10nKey::CmdSshConnectWithInput, + L10nKey::CmdAgentSendSelection, + L10nKey::CmdAgentSendSelectionSubtitle, + L10nKey::CmdAgentSendGitDiffForReview, + L10nKey::CmdAgentSendGitDiffSubtitle, + L10nKey::CmdSettings, + L10nKey::CmdKeyboardShortcuts, + L10nKey::CmdAboutTty7, + L10nKey::CmdCheckForUpdates, + L10nKey::CmdDocumentation, + L10nKey::CmdJoinDiscord, + L10nKey::CmdReportIssue, + L10nKey::CmdRestartServer, + L10nKey::CmdRestartServerSubtitle, + L10nKey::CmdQuitTty7, + L10nKey::CmdQuitTty7Subtitle, + L10nKey::CmdQuickConnect, + L10nKey::CmdQuickConnectSaveProfile, + L10nKey::CmdRecent, + L10nKey::AppRestartServerTitle, + L10nKey::AppRestartServerMismatchDetail, + L10nKey::AppRestartServerDialectDetail, + L10nKey::AppRestartServerDialectNewerDetail, + L10nKey::AppRestartServerOldDetail, + L10nKey::AppRestart, + L10nKey::AppRestartServerNoServer, + L10nKey::AppRestartServerBody, + L10nKey::AppWorktreeRemoveDetailDirty, + L10nKey::AppWorktreeRemoveDetailClean, + L10nKey::AppWorktreeRemoveTitle, + L10nKey::AppWorktreeDiscardAndRemove, + L10nKey::AppWorktreeRemove, + L10nKey::AppWorktreeKeep, + L10nKey::AppReopenTabFailed, + L10nKey::AppOpenTerminalFailed, + L10nKey::AppSshConnectionFailed, + L10nKey::AppSshReconnectFailed, + L10nKey::AppSplitPaneFailed, + L10nKey::AppWorktreeRemoved, + L10nKey::AppWorktreeRemoveFailed, + L10nKey::AppForkStillConnecting, + L10nKey::AppPaneNoCodingAgent, + L10nKey::AppForkNoCommand, + L10nKey::AppForkLocalOnly, + L10nKey::AppForkNoSessionId, + L10nKey::AppForkSessionIdNotToken, + L10nKey::AppForkMidTurn, + L10nKey::AppTabNoWorkingDirectory, + L10nKey::AppNothingSelected, + L10nKey::AppPaneNoKnownDirectory, + L10nKey::AppNoUncommittedChanges, + L10nKey::AppCmdSshProfileTitle, + L10nKey::AppCmdSwitchToTab, + L10nKey::AppPlaceholderDescription, + L10nKey::AppPlaceholderSshQuickConnect, + L10nKey::AppPlaceholderLoginShell, + L10nKey::AppPlaceholderNone, + L10nKey::AppPlaceholderOpenInDefaultApp, + L10nKey::AppThemeColorBackground, + L10nKey::AppThemeColorForeground, + L10nKey::AppThemeColorAccent, + L10nKey::AppThemeColorCursor, + L10nKey::AppThemeColorSelection, + L10nKey::AppAgentHooksThisComputer, + L10nKey::AppAgentHooksRemoteMachine, + L10nKey::AppAgentHooksNoHomeDir, + L10nKey::AppAgentHooksOffline, + L10nKey::AppAgentHooksHomeDirUnresolved, + L10nKey::AppAgentHooksOpFailed, + L10nKey::AppKeybindingDisplacedNote, + L10nKey::AppLocalServerName, + L10nKey::AppSshParseUnbalancedQuotes, + L10nKey::AppSshParseNoRemoteCommands, + L10nKey::AppSshParseFlagNeedsValue, + L10nKey::AppSshParseInvalidPort, + L10nKey::AppSshParseUnsupportedOption, + L10nKey::AppSshParseEnterHost, + L10nKey::AppSshParseBadHost, + L10nKey::AppMenuMinimize, + L10nKey::AppMenuZoom, + L10nKey::SwitcherStatusRestarting, + L10nKey::SwitcherStatusInstalling, + L10nKey::SwitcherStatusConnecting, + L10nKey::SwitcherStatusConnectFailed, + L10nKey::SwitcherStatusNotConnected, + L10nKey::SettingsLanguage, + L10nKey::SettingsLanguageDesc, L10nKey::SettingsLanguageEnglish, L10nKey::SettingsLanguageChinese, L10nKey::SettingsLanguageJapanese, @@ -1341,8 +1996,9 @@ mod tests { L10nKey::SettingsAliasesLinked, L10nKey::SettingsRulesOpenedWithConnection, L10nKey::SettingsOfflineMachines, - L10nKey::PanelUntracked, L10nKey::PanelMoreChangedFiles, + L10nKey::ScmFilesChanged, + L10nKey::ScmStagedFileCount, L10nKey::WindowStopShells, L10nKey::WindowDeleteShells, L10nKey::DiffChangedFiles, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 3b21768a..1761731e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -848,7 +848,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileTooLarge => "“{path}”太大,无法在编辑器中打开({size} MB)", L10nKey::EditorBinaryFile => "“{path}”看起来是二进制文件", L10nKey::PanelInfoTitle => "信息", - L10nKey::PanelChangesTitle => "变更", + L10nKey::PanelChangesTitle => "源代码管理", + L10nKey::PanelScmTitle => "源代码管理", L10nKey::PanelFilesTitle => "文件", L10nKey::PanelNoSession => "没有活动会话。", L10nKey::PanelNoSessionHint => "打开一个标签页以在此处查看其 shell、目录和进程。", @@ -874,6 +875,91 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelAgentDone => "已完成", L10nKey::PanelRevealInFinder => "在 Finder 中显示", L10nKey::PanelOpenFolder => "打开文件夹", + L10nKey::ScmGroupMerge => "合并冲突", + L10nKey::ScmGroupStaged => "暂存的更改", + L10nKey::ScmGroupChanges => "更改", + L10nKey::ScmGroupUntracked => "未跟踪", + L10nKey::ScmCommitPlaceholder => "说说改了什么…", + L10nKey::ScmCommitButton => "提交", + L10nKey::ScmCommitAllButton => "提交全部", + L10nKey::ScmCommitAmendButton => "提交(修订)", + L10nKey::ScmCommitAndPush => "提交并推送", + L10nKey::ScmCommitAndSync => "提交并同步", + L10nKey::ScmAmendLastCommit => "修订上一次提交", + L10nKey::ScmCommitStaged => "提交已暂存的更改", + L10nKey::ScmStashAll => "全部贮藏", + L10nKey::ScmNothingToCommit => "没有可提交的内容", + L10nKey::ScmNetworkBusy => "这个仓库还有一个网络操作在进行中", + L10nKey::ScmCommitNeedsMessage => "先写一条提交信息", + L10nKey::ScmDetailFilesFailed => "无法读取文件列表", + L10nKey::ScmTimeNow => "刚刚", + L10nKey::ScmTimeMinutes => "{n}分", + L10nKey::ScmTimeHours => "{n}时", + L10nKey::ScmTimeDays => "{n}天", + // "个月" 而不是 "月":"3月" 会被读成月份名。 + L10nKey::ScmTimeMonths => "{n}个月", + L10nKey::ScmTimeYears => "{n}年", + L10nKey::ScmResetHardConfirm => { + "把分支重置到这个提交?之后的提交会从分支上消失,未提交的更改会被丢弃。" + } + L10nKey::ScmReset => "重置", + L10nKey::ScmChipStaged => "已暂存", + L10nKey::ScmStage => "暂存更改", + L10nKey::ScmStageAll => "暂存全部更改", + L10nKey::ScmUnstage => "取消暂存", + L10nKey::ScmUnstageAll => "取消暂存全部更改", + L10nKey::ScmDiscard => "放弃更改", + L10nKey::ScmDiscardAll => "放弃全部更改", + L10nKey::ScmDiscardConfirm => "放弃对 {path} 的更改?此操作无法撤销。", + L10nKey::ScmOpenConflict => "解决冲突", + L10nKey::ScmMarkResolved => "标记为已解决", + L10nKey::ScmUnrepresentablePath => "该路径不是合法的 UTF-8,无法传给 git —— 仅可查看。", + L10nKey::ScmPublishBranch => "发布分支", + L10nKey::ScmDetached => "游离头指针", + L10nKey::ScmAmendBadge => "修订", + L10nKey::ScmSync => "同步更改", + L10nKey::ScmPush => "推送", + L10nKey::ScmPull => "拉取", + L10nKey::ScmFetch => "获取", + L10nKey::ScmCheckoutBranch => "切换到…", + L10nKey::ScmCreateBranch => "新建分支…", + L10nKey::ScmSearchBranches => "搜索分支…", + L10nKey::ScmStashAndSwitch => "贮藏并切换", + L10nKey::ScmGraphTitle => "提交历史", + L10nKey::ScmGraphLoadMore => "加载更多", + L10nKey::ScmGraphFilterPlaceholder => "筛选提交…", + L10nKey::ScmGraphAllBranches => "全部分支", + L10nKey::ScmGraphEmpty => "还没有提交", + L10nKey::ScmGraphCurrentBranch => "当前分支", + L10nKey::ScmCheckoutCommit => "检出此提交", + L10nKey::ScmCreateBranchHere => "在此创建分支…", + L10nKey::ScmResetSoft => "重置(保留暂存)", + L10nKey::ScmResetMixed => "重置(保留工作区)", + L10nKey::ScmResetHard => "重置(丢弃更改)", + L10nKey::ScmCommitDetailTitle => "提交", + L10nKey::ScmCopyCommitSha => "复制提交 SHA", + L10nKey::ScmCherryPick => "拣选提交", + L10nKey::ScmRevertCommit => "还原提交", + L10nKey::ScmResetToCommit => "重置到该提交", + L10nKey::ScmRefresh => "刷新", + L10nKey::ScmBackToChanges => "返回", + L10nKey::ScmCommitParents => "父提交", + L10nKey::ScmShowMore => "展开", + L10nKey::ScmShowLess => "收起", + L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", + L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", + L10nKey::ScmOpenChanges => "查看改动", + L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。", + L10nKey::ScmAmendConfirm => { + "修补上一次提交?它会被一个新提交取代,已经拿到旧提交的人需要自行处理。" + } + L10nKey::ScmOpMerge => "合并中", + L10nKey::ScmOpRebase => "变基中", + L10nKey::ScmOpCherryPick => "拣选中", + L10nKey::ScmOpRevert => "还原中", + L10nKey::ScmOpBisect => "二分查找中", + L10nKey::ScmOpAm => "应用补丁中", + L10nKey::ScmSwitchRepository => "切换仓库", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "删除", L10nKey::WindowThisWorkspace => "此工作区", @@ -909,6 +995,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::DiffBudget => "tty7 的预算", L10nKey::DiffPerFileCap => "单文件上限", L10nKey::DiffUntrackedSummary => "{count} 个未跟踪", + L10nKey::DiffViewSplit => "并排", + L10nKey::DiffViewUnified => "统一", L10nKey::PendingConnecting => "正在连接 {machine}…", L10nKey::PendingUnreachable => "无法连接到 {machine}", L10nKey::WorktreePromptNeedsName => "worktree 需要一个名称", @@ -1065,6 +1153,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdGroupTabsPanes => "标签页与窗格", L10nKey::CmdGroupWorkspaces => "工作区", L10nKey::CmdGroupView => "视图", + L10nKey::CmdGroupGit => "Git", L10nKey::CmdGroupTerminal => "终端", L10nKey::CmdGroupSsh => "SSH", L10nKey::CmdGroupAgents => "Agents", @@ -1121,6 +1210,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdResetFontSize => "重置字号", L10nKey::CmdEnterFullScreen => "进入全屏", L10nKey::CmdClearScrollback => "清除回滚内容", + L10nKey::CmdToggleDiffViewMode => "切换统一 / 并排差异视图", + L10nKey::CmdGitCommit => "Git:提交", + L10nKey::CmdGitStageAll => "Git:暂存全部更改", + L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改", + L10nKey::CmdGitDiscardAll => "Git:放弃全部更改", + L10nKey::CmdGitDiscardAllSubtitle => "丢弃工作区里所有未提交的更改。", + L10nKey::CmdGitCheckoutTo => "Git:切换到…", + L10nKey::CmdGitCreateBranch => "Git:新建分支…", + L10nKey::CmdGitSync => "Git:同步", + L10nKey::CmdGitSyncSubtitle => "先拉取,再推送。", + L10nKey::CmdGitPush => "Git:推送", + L10nKey::CmdGitPull => "Git:拉取", + L10nKey::CmdGitFetch => "Git:获取", + L10nKey::CmdGitToggleGraph => "Git:显示 / 隐藏提交历史", + L10nKey::CmdClearScrollback => "清除 scrollback", L10nKey::CmdFindInTerminal => "在终端中查找…", L10nKey::CmdFindNext => "查找下一个", L10nKey::CmdFindPrevious => "查找上一个", @@ -1309,7 +1413,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { 包括那里打印过的一切:token、`env` 的输出、agent 的对话记录。关掉则这些输出只存在于内存里。" } L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。", - L10nKey::PanelUntracked => "{count} 个未跟踪文件", + L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", + L10nKey::ScmFilesChanged => "{count} 个文件改动", + L10nKey::ScmStagedFileCount => "已暂存 {count} 个文件", L10nKey::AppMenuAbout => "关于 tty7", L10nKey::AppMenuCheckForUpdates => "检查更新…", L10nKey::AppMenuSettings => "设置…", @@ -1389,6 +1495,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SidebarScratchGroup => "草稿", L10nKey::TabContextCloseTab => "关闭标签页", L10nKey::TabContextCloseTabsBelow => "关闭下方标签页", + L10nKey::AppAgentHooksOpFailed => "失败:{error}", + L10nKey::AppMenuEnterFullscreen => "进入全屏", + L10nKey::HomeTimeOverWeekAgo => "一周多前", + L10nKey::Search => "搜索", + L10nKey::SettingsDaemonStaleRestart => "重启 server", + L10nKey::SettingsNoneLower => "无", + L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具", L10nKey::TabContextMarkUnread => "标记为未读", }) } @@ -1414,11 +1527,16 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SftpReplaceBody, "other") => "{names} 在这个文件夹里已经存在,上传会覆盖它们。", (L10nKey::AppTabsNotRestored, "one") => "上次的 1 个标签页没能重新打开", (L10nKey::AppTabsNotRestored, "other") => "上次的 {count} 个标签页没能重新打开", - (L10nKey::PanelUntracked, "zero") => "0 个未跟踪文件", - (L10nKey::PanelUntracked, "one") => "1 个未跟踪文件", - (L10nKey::PanelUntracked, "other") => "{count} 个未跟踪文件", (L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 git diff 查看。", (L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 git diff 查看。", + (L10nKey::ScmFilesChanged, "zero") => "没有文件改动", + (L10nKey::ScmFilesChanged, "one") => "1 个文件改动", + (L10nKey::ScmFilesChanged, "other") => "{count} 个文件改动", + (L10nKey::ScmStagedFileCount, "zero") => "没有暂存的更改", + (L10nKey::ScmStagedFileCount, "one") => "已暂存 1 个文件", + (L10nKey::ScmStagedFileCount, "other") => "已暂存 {count} 个文件", + (L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 `git diff` 查看。", + (L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 `git diff` 查看。", (L10nKey::PanelMoreChangedFiles, "other") => { "…还有 {count} 个变更文件——运行 git diff 查看。" } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index a6cb5a2f..73e34246 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -329,6 +329,23 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { }, ), ("ZoomWindow", ""), + // `secondary-enter` is `ToggleFullscreen` on macOS. That is not a + // clash: `ScmCommit` binds inside the `ScmCommit` key context, so it + // only wins while the caret sits in the commit box. + ("ScmCommit", "secondary-enter"), + ("ScmCommitAmend", ""), + ("ScmStageAll", ""), + ("ScmUnstageAll", ""), + ("ScmDiscardAll", ""), + ("ScmRefresh", ""), + ("ScmSync", ""), + ("ScmPush", ""), + ("ScmPull", ""), + ("ScmFetch", ""), + ("ScmCheckoutBranch", ""), + ("ScmCreateBranch", ""), + ("ScmToggleGraph", ""), + ("ToggleDiffViewMode", ""), ("ToggleSftp", ""), ("ShowSshForwards", ""), ("ToggleCodePanel", "secondary-shift-e"), @@ -636,6 +653,31 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Application, t(L10nKey::CmdQuitTty7).to_string(), ), + // The source control verbs wear their palette names, so the + // Keybindings page and the palette agree on what a chord does. + "ScmCommit" => (CommandGroup::Git, t(L10nKey::CmdGitCommit).to_string()), + "ScmCommitAmend" => ( + CommandGroup::Git, + t(L10nKey::ScmAmendLastCommit).to_string(), + ), + "ScmStageAll" => (CommandGroup::Git, t(L10nKey::CmdGitStageAll).to_string()), + "ScmUnstageAll" => (CommandGroup::Git, t(L10nKey::CmdGitUnstageAll).to_string()), + "ScmDiscardAll" => (CommandGroup::Git, t(L10nKey::CmdGitDiscardAll).to_string()), + "ScmRefresh" => (CommandGroup::Git, t(L10nKey::ScmRefresh).to_string()), + "ScmSync" => (CommandGroup::Git, t(L10nKey::CmdGitSync).to_string()), + "ScmPush" => (CommandGroup::Git, t(L10nKey::CmdGitPush).to_string()), + "ScmPull" => (CommandGroup::Git, t(L10nKey::CmdGitPull).to_string()), + "ScmFetch" => (CommandGroup::Git, t(L10nKey::CmdGitFetch).to_string()), + "ScmCheckoutBranch" => (CommandGroup::Git, t(L10nKey::CmdGitCheckoutTo).to_string()), + "ScmCreateBranch" => ( + CommandGroup::Git, + t(L10nKey::CmdGitCreateBranch).to_string(), + ), + "ScmToggleGraph" => (CommandGroup::Git, t(L10nKey::CmdGitToggleGraph).to_string()), + "ToggleDiffViewMode" => ( + CommandGroup::View, + t(L10nKey::CmdToggleDiffViewMode).to_string(), + ), _ => return None, }) } @@ -845,6 +887,7 @@ fn action_context(action: &str) -> Option<&'static str> { match action { "FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline" | "CopyText" | "PasteText" => Some("Terminal"), + "ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"), _ => None, } } @@ -917,6 +960,20 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None), "ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None), "ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None), + "ScmCommit" => KeyBinding::new(keystroke, ScmCommit, action_context(action)), + "ScmCommitAmend" => KeyBinding::new(keystroke, ScmCommitAmend, action_context(action)), + "ScmStageAll" => KeyBinding::new(keystroke, ScmStageAll, None), + "ScmUnstageAll" => KeyBinding::new(keystroke, ScmUnstageAll, None), + "ScmDiscardAll" => KeyBinding::new(keystroke, ScmDiscardAll, None), + "ScmRefresh" => KeyBinding::new(keystroke, ScmRefresh, None), + "ScmSync" => KeyBinding::new(keystroke, ScmSync, None), + "ScmPush" => KeyBinding::new(keystroke, ScmPush, None), + "ScmPull" => KeyBinding::new(keystroke, ScmPull, None), + "ScmFetch" => KeyBinding::new(keystroke, ScmFetch, None), + "ScmCheckoutBranch" => KeyBinding::new(keystroke, ScmCheckoutBranch, None), + "ScmCreateBranch" => KeyBinding::new(keystroke, ScmCreateBranch, None), + "ScmToggleGraph" => KeyBinding::new(keystroke, ScmToggleGraph, None), + "ToggleDiffViewMode" => KeyBinding::new(keystroke, ToggleDiffViewMode, None), "FindInTerminal" => KeyBinding::new(keystroke, FindInTerminal, action_context(action)), "FindNext" => KeyBinding::new(keystroke, FindNext, action_context(action)), "FindPrevious" => KeyBinding::new(keystroke, FindPrevious, action_context(action)), @@ -1063,6 +1120,32 @@ mod tests { } } + #[test] + fn every_action_has_a_binding_arm() { + // The sibling test above only reaches actions that ship with a default + // keystroke, which leaves the unbound ones — the majority — free to be + // listed in `default_bindings` with no `make_binding` arm behind them. + // Nothing surfaces that: the action shows up in Settings, the user + // assigns a key, and the key silently does nothing. + for (action, _) in default_bindings() { + assert!( + make_binding(action, "ctrl-f12").is_some(), + "no make_binding arm for action {action}; \ + anyone who binds a key to it in Settings gets nothing" + ); + } + } + + #[test] + fn the_commit_key_only_fires_inside_the_commit_box() { + // `secondary-enter` is `ToggleFullscreen` on macOS. The two coexist + // only because the commit binding is scoped; drop the context and + // committing steals full screen everywhere. + assert_eq!(action_context("ScmCommit"), Some("ScmCommit")); + assert_eq!(action_context("ScmCommitAmend"), Some("ScmCommit")); + assert_eq!(action_context("ToggleFullscreen"), None); + } + #[test] fn tmux_preset_keystrokes_all_parse_and_map_to_actions() { for (action, key) in tmux_preset("ctrl-b") { @@ -1310,15 +1393,23 @@ mod tests { #[test] fn every_default_chord_is_claimed_by_exactly_one_action() { - let mut seen: Vec<(&str, &str)> = Vec::new(); + // Per context, not globally: gpui resolves a keystroke by walking the + // focus chain outwards, so a chord bound inside a narrow context and + // again with no context is not a clash — the narrow one wins while + // that element has focus and the global one applies everywhere else. + // `ScmCommit` and `ToggleFullscreen` both take secondary-enter on that + // basis. Two bindings sharing a chord *and* a context is still a bug, + // because then which one fires is arbitrary. + let mut seen: Vec<(&str, &str, Option<&'static str>)> = Vec::new(); for (action, spec) in default_bindings() { if spec.is_empty() { continue; } - if let Some((other, _)) = seen.iter().find(|(_, s)| *s == spec) { - panic!("{action} and {other} both claim {spec}"); + let context = action_context(action); + if let Some((other, _, _)) = seen.iter().find(|(_, s, c)| *s == spec && *c == context) { + panic!("{action} and {other} both claim {spec} in context {context:?}"); } - seen.push((action, spec)); + seen.push((action, spec, context)); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9bc7880e..106b46fb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod assets; pub mod code_editor; pub mod diff_overlay; +pub mod diff_rows; pub mod file_copy; pub mod file_tree; pub mod forwards; @@ -27,6 +28,7 @@ pub mod remote_workspace; pub mod reorder; pub mod right_panel; pub mod rounding; +pub mod scm; pub mod scrollbar; pub mod settings; pub mod sftp; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index d11bb4c2..8fef019c 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -76,6 +76,22 @@ pub enum CommandKind { ShowSshForwards, ToggleCodePanel, RestartSshSession, + ScmCommit, + ScmStageAll, + ScmUnstageAll, + ScmDiscardAll, + ScmPush, + ScmPull, + ScmFetch, + ScmSync, + ScmCreateBranch, + OpenBranchPicker, + /// One branch, filled in by the picker. Dynamic like `OpenSshConnect`, so + /// it gets no stable id and no key spec. Nothing emits it until the picker + /// can list refs. + #[allow(dead_code)] + CheckoutBranch(String), + ToggleDiffViewMode, SendSelectionToAgent, SendGitDiffToAgent, OpenThemePicker, @@ -140,7 +156,9 @@ impl CommandKind { ToggleLeftPanel => "left-sidebar", ToggleRightPanel => "right-panel", ShowRightPanel(RightPanelTab::Info) => "right-panel-info", - ShowRightPanel(RightPanelTab::Changes) => "right-panel-changes", + // Frecency is keyed by this string, so it stays `right-panel-changes` + // even though the panel is now called Source Control. + ShowRightPanel(RightPanelTab::Scm) => "right-panel-changes", ShowRightPanel(RightPanelTab::Files) => "right-panel-files", ClearTerminal => "clear-scrollback", FindInTerminal => "find", @@ -164,12 +182,24 @@ impl CommandKind { ShowSshForwards => "ssh-port-forwarding", ToggleCodePanel => "code-panel", RestartSshSession => "ssh-reconnect", + ScmCommit => "git-commit", + ScmStageAll => "git-stage-all", + ScmUnstageAll => "git-unstage-all", + ScmDiscardAll => "git-discard-all", + ScmPush => "git-push", + ScmPull => "git-pull", + ScmFetch => "git-fetch", + ScmSync => "git-sync", + ScmCreateBranch => "git-create-branch", + OpenBranchPicker => "git-checkout", + ToggleDiffViewMode => "diff-view-mode", SendSelectionToAgent => "agent-send-selection", SendGitDiffToAgent => "agent-send-diff", OpenThemePicker => "change-theme", OpenSshConnectInput => "ssh-add-connection", OpenSshProfiles => "ssh-manage-profiles", OpenSshConnect(_) + | CheckoutBranch(_) | SetTheme(_) | ActivateTab(_) | ConnectSavedProfile(_) @@ -229,7 +259,7 @@ impl CommandKind { ToggleRightPanel => "ToggleRightPanel", ShowRightPanel(tab) => match tab { RightPanelTab::Info => "ShowRightPanelInfo", - RightPanelTab::Changes => "ShowRightPanelChanges", + RightPanelTab::Scm => "ShowRightPanelChanges", RightPanelTab::Files => "ShowRightPanelFiles", }, ClearTerminal => "ClearScrollback", @@ -251,6 +281,17 @@ impl CommandKind { ToggleCodePanel => "ToggleCodePanel", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", + ScmCommit => "ScmCommit", + ScmStageAll => "ScmStageAll", + ScmUnstageAll => "ScmUnstageAll", + ScmDiscardAll => "ScmDiscardAll", + ScmPush => "ScmPush", + ScmPull => "ScmPull", + ScmFetch => "ScmFetch", + ScmSync => "ScmSync", + ScmCreateBranch => "ScmCreateBranch", + OpenBranchPicker => "ScmCheckoutBranch", + ToggleDiffViewMode => "ToggleDiffViewMode", CopyText | CutText | PasteText @@ -261,6 +302,7 @@ impl CommandKind { | OpenThemePicker | OpenSshConnectInput | OpenSshConnect(_) + | CheckoutBranch(_) | SetTheme(_) | ActivateTab(_) | ConnectSavedProfile(_) @@ -277,6 +319,7 @@ pub enum CommandGroup { TabsPanes, Workspaces, View, + Git, Terminal, Ssh, Agents, @@ -284,10 +327,11 @@ pub enum CommandGroup { } impl CommandGroup { - pub(crate) const ORDER: [CommandGroup; 7] = [ + pub(crate) const ORDER: [CommandGroup; 8] = [ CommandGroup::TabsPanes, CommandGroup::Workspaces, CommandGroup::View, + CommandGroup::Git, CommandGroup::Terminal, CommandGroup::Ssh, CommandGroup::Agents, @@ -299,6 +343,7 @@ impl CommandGroup { CommandGroup::TabsPanes => t(L10nKey::CmdGroupTabsPanes), CommandGroup::Workspaces => t(L10nKey::CmdGroupWorkspaces), CommandGroup::View => t(L10nKey::CmdGroupView), + CommandGroup::Git => t(L10nKey::CmdGroupGit), CommandGroup::Terminal => t(L10nKey::CmdGroupTerminal), CommandGroup::Ssh => t(L10nKey::CmdGroupSsh), CommandGroup::Agents => t(L10nKey::CmdGroupAgents), @@ -439,7 +484,7 @@ impl Command { ), Command::localized( L10nKey::CmdRightPanelChanges, - ShowRightPanel(RightPanelTab::Changes), + ShowRightPanel(RightPanelTab::Scm), ), Command::localized( L10nKey::CmdRightPanelFiles, @@ -448,6 +493,24 @@ impl Command { Command::localized(L10nKey::CmdChangeTheme, OpenThemePicker), Command::localized(L10nKey::CmdResetFontSize, ResetFontSize), Command::localized(L10nKey::CmdEnterFullScreen, ToggleFullscreen), + Command::localized(L10nKey::CmdToggleDiffViewMode, ToggleDiffViewMode), + ]; + + // Their own group rather than more entries under View: View is a list + // of things to show and hide, and ten git verbs in it would drown that. + let git = [ + Command::localized(L10nKey::CmdGitCommit, ScmCommit), + Command::localized(L10nKey::CmdGitStageAll, ScmStageAll), + Command::localized(L10nKey::CmdGitUnstageAll, ScmUnstageAll), + Command::localized(L10nKey::CmdGitDiscardAll, ScmDiscardAll) + .with_subtitle(t(L10nKey::CmdGitDiscardAllSubtitle)), + Command::localized(L10nKey::CmdGitCheckoutTo, OpenBranchPicker), + Command::localized(L10nKey::CmdGitCreateBranch, ScmCreateBranch), + Command::localized(L10nKey::CmdGitSync, ScmSync) + .with_subtitle(t(L10nKey::CmdGitSyncSubtitle)), + Command::localized(L10nKey::CmdGitPush, ScmPush), + Command::localized(L10nKey::CmdGitPull, ScmPull), + Command::localized(L10nKey::CmdGitFetch, ScmFetch), ]; let terminal = [ @@ -497,6 +560,7 @@ impl Command { push(tabs.into(), CommandGroup::TabsPanes); push(workspaces.into(), CommandGroup::Workspaces); push(view.into(), CommandGroup::View); + push(git.into(), CommandGroup::Git); push(terminal.into(), CommandGroup::Terminal); push(ssh.into(), CommandGroup::Ssh); push(agents.into(), CommandGroup::Agents); @@ -1431,3 +1495,54 @@ mod tests { assert!(CommandKind::QuickConnect("a@b".into()).id().is_none()); } } + +#[cfg(test)] +mod gpui_tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn every_palette_command_has_a_stable_id(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + cx.update(|cx| { + cx.set_global(Config::default()); + crate::ui::i18n::set_locale("en"); + let chrome = ChromeState { + rail_collapsed: false, + right_panel_visible: false, + }; + let mut seen = std::collections::HashSet::new(); + for cmd in Command::base_commands(cx, chrome) { + // Frecency is keyed by this string. A command without one is + // never learned, so it never rises in the list no matter how + // often it is run. + let id = cmd + .kind + .id() + .unwrap_or_else(|| panic!("`{}` has no stable id", cmd.title)); + assert!(seen.insert(id), "two commands claim the id {id:?}"); + } + }); + } + + #[gpui::test] + fn the_git_group_is_its_own_section(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + cx.update(|cx| { + cx.set_global(Config::default()); + crate::ui::i18n::set_locale("en"); + let chrome = ChromeState { + rail_collapsed: false, + right_panel_visible: false, + }; + let cmds = Command::base_commands(cx, chrome); + let git = cmds.iter().filter(|c| c.group == CommandGroup::Git).count(); + assert_eq!(git, 10, "the git section should hold ten verbs"); + // View stays a list of things to show and hide. + assert!( + !cmds.iter().any(|c| c.group == CommandGroup::View + && c.kind.id().unwrap_or("").starts_with("git-")), + ); + }); + } +} diff --git a/src/ui/presets.rs b/src/ui/presets.rs index eaa0d70d..dd0440a7 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -123,6 +123,26 @@ pub struct ActiveAccent(pub u32); impl Global for ActiveAccent {} +/// How many lanes of the commit graph get a colour of their own. +/// +/// Six because that is how many hues of the ANSI set survive being pulled to a +/// contrast floor while staying apart from each other — and because the graph +/// caps its visible lanes at the same number, which is what guarantees no two +/// columns on screen are ever the same colour. +pub const LANE_SLOTS: usize = 6; + +#[derive(Debug, Clone, Copy)] +pub struct Lanes { + pub ink: [u32; LANE_SLOTS], + /// Everything past the last slot shares one column, so it gets a neutral: + /// a hue there would claim a branch identity the column does not have. + pub overflow: u32, +} + +pub struct ActiveLanes(pub Lanes); + +impl Global for ActiveLanes {} + impl Theme { pub fn background_color(&self) -> u32 { self.background.color() @@ -226,6 +246,38 @@ impl Theme { } } + /// Lane colours for the commit graph, derived the same way every other + /// colour in this file is: seeded from the theme's own palette, then walked + /// to a contrast floor on each surface it can be painted on. + /// + /// Not a fixed table of hexes. A hard-coded palette would be the one thing + /// here that does not follow the theme, and — worse — the contrast tests + /// below cannot see it, so the four light builtins would ship a graph whose + /// lanes sit at 2:1 against their own background. + /// + /// The seed order is blue, yellow, magenta, green, cyan, red. Three + /// constraints picked it: no two adjacent slots share a hue family; red and + /// green are never neighbours, for the readers who cannot tell them apart; + /// and red is last because a panel three or four lanes wide never reaches + /// it, so the one colour that also means "danger" everywhere else in the UI + /// stays out of the common case. + pub fn lanes(&self) -> Lanes { + const SEEDS: [usize; LANE_SLOTS] = [4, 3, 5, 2, 6, 1]; + let bg = self.background_color(); + let fg = legible_foreground(bg, self.foreground); + // Through `clear_ink`, the same three surfaces `semantics` clears on: + // the graph draws on the window in a floating panel, on the sidebar + // when the panel is docked, and on a popover in the detail view. + let mut ink = [0u32; LANE_SLOTS]; + for (slot, seed) in SEEDS.iter().enumerate() { + ink[slot] = self.clear_ink(self.ansi_seed(*seed), ACCENT_FLOOR); + } + Lanes { + ink, + overflow: dim(fg, bg, state::TEXT_RESTING), + } + } + pub fn surfaces(&self) -> Surfaces { let m = self.neutrals(); let mut sidebar = self.surface(m.sidebar); @@ -1421,6 +1473,122 @@ mod tests { } } + /// CIE L*a*b* for a packed sRGB colour, D65. + /// + /// Contrast is a luminance ratio and says nothing about hue: two lanes can + /// both clear 3:1 against the background and still be the same colour to + /// look at. ΔE is the measure that catches that, and it needs Lab. + fn lab(c: u32) -> (f32, f32, f32) { + fn linear(v: u32) -> f32 { + let s = v as f32 / 255.0; + if s <= 0.04045 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + } + let (r, g, b) = ( + linear(c >> 16 & 0xff), + linear(c >> 8 & 0xff), + linear(c & 0xff), + ); + // sRGB → XYZ, then normalised by the D65 white point. + let x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047; + let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883; + let f = |t: f32| { + if t > 0.008856 { + t.cbrt() + } else { + 7.787 * t + 16.0 / 116.0 + } + }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) + } + + fn delta_e76(a: u32, b: u32) -> f32 { + let (l1, a1, b1) = lab(a); + let (l2, a2, b2) = lab(b); + ((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt() + } + + #[test] + fn lane_colours_clear_the_floor_on_every_surface() { + for t in builtins() { + let bg = t.background_color(); + let fg = legible_foreground(bg, t.foreground); + let lanes = t.lanes(); + for (name, surface) in [ + ("background", bg), + ("sidebar", mix(bg, fg, 0.03)), + ("popover", mix(bg, fg, 0.05)), + ] { + for (slot, ink) in lanes.ink.iter().enumerate() { + let ratio = contrast(*ink, surface); + assert!( + ratio >= ACCENT_FLOOR, + "{}/{name}: lane {slot} is only {ratio:.2}:1", + t.id + ); + } + let ratio = contrast(lanes.overflow, surface); + assert!( + ratio >= ACCENT_FLOOR, + "{}/{name}: the overflow lane is only {ratio:.2}:1", + t.id + ); + } + } + } + + #[test] + fn adjacent_lanes_are_never_the_same_colour() { + // A just-noticeable difference is around 2.3. The floor is set far + // above it because these are 1.5px lines a few pixels apart, not + // patches side by side, and the eye is much worse at hairlines. + const FLOOR: f32 = 12.0; + for t in builtins() { + let lanes = t.lanes(); + for slot in 0..LANE_SLOTS - 1 { + let d = delta_e76(lanes.ink[slot], lanes.ink[slot + 1]); + assert!( + d >= FLOOR, + "{}: lanes {slot} and {} are ΔE {d:.1} apart", + t.id, + slot + 1 + ); + } + } + } + + #[test] + fn lane_colours_are_deterministic() { + for t in builtins() { + assert_eq!( + t.lanes().ink, + t.lanes().ink, + "{}: lane derivation is not a pure function", + t.id + ); + } + } + + /// The seeds were chosen so that no two neighbours share a hue family and + /// red never sits beside green. Both are properties of the *order*, so a + /// reshuffle has to fail here rather than only looking slightly worse. + #[test] + fn the_lane_seed_order_keeps_red_and_green_apart() { + let seeds = [4usize, 3, 5, 2, 6, 1]; + let red = seeds.iter().position(|s| *s == 1).expect("red is a seed"); + let green = seeds.iter().position(|s| *s == 2).expect("green is a seed"); + assert!( + red.abs_diff(green) > 1, + "red and green ended up adjacent at slots {red} and {green}" + ); + assert_eq!(red, LANE_SLOTS - 1, "red should be the last slot reached"); + } + #[test] fn resting_labels_stay_readable() { for t in builtins() { diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 8bc330b7..4cd48f90 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -5,16 +5,14 @@ use gpui_component::{ ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, }; use std::path::PathBuf; -use std::sync::Arc; use crate::core::config::{Config, RightPanelTab}; use crate::daemon::protocol::PaneProcs; -use crate::terminal::git_diff::{DiffSnapshot, MAX_RENDERED_FILES}; use crate::ui::app::{ CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset, tile_trailing_inset_sm, }; -use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::i18n::{L10nKey, t}; use crate::ui::scrollbar::with_vertical_scrollbar; pub(crate) const MIN_WIDTH: f32 = 216.; @@ -48,11 +46,33 @@ pub(crate) const META_MONO: f32 = META - STEP; /// sidebar's group headings, which are the same thing one panel over. const HEADING: f32 = 11. * STEP; +// The right panel's type ramp: four steps, half a point apart, that the Info +// and Source Control tabs draw from so switching between them does not change +// the apparent size of the panel. (The Files tab, in `file_tree.rs`, is the +// one holdout — it renders its rows with `text_sm()` = 14px.) The steps are +// close together on purpose: the panel is a dense aside next to the terminal, +// and the differences between them are meant to be felt as hierarchy rather +// than seen as different type sizes. +// +// (The px constants that used to live here — PANEL_TEXT and its steps — were +// superseded by the interface font scale's rems tokens; the Source Control +// panel still names its own px steps locally until it moves onto that scale.) + +/// Height of the search strip. +/// +/// gpui-component sizes an `Input` border-box, and `.xsmall()` is +/// `input_h(Size::XSmall)` = `h_5()` = 20px: one `LINE_HEIGHT` of `Rems(1.25)` +/// = 20px with `input_py(Size::XSmall)` = 0 above and below. (`.appearance(false)` +/// only drops the background, border and radius; the padding and the height +/// stay.) Thirty leaves that field 5px of slack top and bottom. +/// +/// Load-bearing beyond this file: `scm/panel.rs` pins its commit box to the +/// same height with a `const _: () = assert!(…)`, so the two tabs' top strips +/// line up. +pub(crate) const SEARCH_H: f32 = 30.; + #[derive(Default)] pub(crate) struct RightPanelState { - pub(crate) diff_cwd: Option<(crate::ui::host_ops::HostId, PathBuf)>, - pub(crate) diff: Option>>, - pub(crate) diff_pending: Option<(crate::ui::host_ops::HostId, PathBuf)>, pub(crate) procs_pane: Option, pub(crate) procs: Option, pub(crate) procs_loading: bool, @@ -164,7 +184,7 @@ impl Tty7App { let body = match tab { RightPanelTab::Info => self.render_panel_info(window, cx), - RightPanelTab::Changes => self.render_panel_changes(window, cx), + RightPanelTab::Scm => self.render_panel_scm(window, cx), RightPanelTab::Files => self.render_panel_files(window, cx), }; let (backing, handle) = self.right_panel_resize(cx); @@ -340,6 +360,9 @@ impl Tty7App { .items_baseline() .gap(px(7.)) .child( + // The title step of the panel ramp, SEMIBOLD and + // uppercased. It reads as a label rather than as + // content because of the weight and the caps. div() .text_size(rems(META)) .font_weight(gpui::FontWeight::SEMIBOLD) @@ -348,6 +371,8 @@ impl Tty7App { ) .when_some(count, |this, c| { this.child( + // A count is a token hanging off the heading, not + // part of it: one step down, mono, regular weight. div() .text_size(rems(META_MONO)) .font_family(cx.theme().mono_font_family.clone()) @@ -379,8 +404,10 @@ impl Tty7App { h_flex() .flex_none() .items_center() + // 8 here plus the `.xsmall()` field's own 4px of leading padding + // is 12px of daylight between the glyph and the first character. .gap(px(8.)) - .h(px(30.)) + .h(px(SEARCH_H)) .px(px(CONTENT_INSET)) .child( Icon::new(IconName::Search) @@ -396,7 +423,7 @@ impl Tty7App { .into_any_element() } - fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement { + pub(crate) fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement { let body = div() .id("right-panel-body") .flex_1() @@ -416,7 +443,12 @@ impl Tty7App { .into_any_element() } - fn panel_empty(&self, text: &str, hint: Option<&str>, cx: &mut Context) -> AnyElement { + pub(crate) fn panel_empty( + &self, + text: &str, + hint: Option<&str>, + cx: &mut Context, + ) -> AnyElement { let muted = cx.theme().muted_foreground; v_flex() .px(px(CONTENT_INSET)) @@ -637,6 +669,9 @@ impl Tty7App { })) .pb(px(if trailing.is_some() { 0. } else { 4. })) .child( + // A group header sits below the panel's own title in the + // hierarchy, so it sits below it in the ramp too: the smallest + // step, carried by weight and caps rather than by size. div() .text_size(rems(HEADING)) .font_weight(gpui::FontWeight::SEMIBOLD) @@ -810,207 +845,6 @@ impl Tty7App { .detach(); } - fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let sf = cx.global::().sidebar; - let target = self - .tabs - .get(self.active) - .and_then(|t| t.detail_pane(window, cx)) - .and_then(|leaf| { - let v = leaf.read(cx); - let cwd = v - .git_status_cwd() - .map(|p| p.to_path_buf()) - .or_else(|| v.host_cwd())?; - Some((v.host(cx)?, cwd)) - }); - - let Some((host, cwd)) = target else { - let title = self.panel_title(t(L10nKey::PanelChangesTitle), None, None, window, cx); - return self.panel_scroll( - self.panel_empty( - t(L10nKey::PanelNoWorkingDirectory), - Some(t(L10nKey::PanelNoWorkingDirectoryHint)), - cx, - ), - title, - ); - }; - let key = (host.id(), cwd.clone()); - if self.right_panel.diff_cwd.as_ref() != Some(&key) { - self.right_panel.diff_cwd = Some(key); - self.right_panel.diff = None; - self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); - } else if self.right_panel.diff.is_none() && self.right_panel.diff_pending.is_none() { - self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); - } - - let count = match &self.right_panel.diff { - Some(Some(snap)) => { - let n = snap.files.len() + snap.untracked_count(); - (n > 0).then(|| n.to_string()) - } - _ => None, - }; - let title = self.panel_title(t(L10nKey::PanelChangesTitle), count, None, window, cx); - let mono = cx.theme().mono_font_family.clone(); - - let inner = match &self.right_panel.diff { - None => self.panel_empty(t(L10nKey::PanelLoading), None, cx), - Some(None) => self.panel_empty( - t(L10nKey::PanelNotAGitRepo), - Some(t(L10nKey::PanelNotAGitRepoHint)), - cx, - ), - Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self - .panel_empty( - t(L10nKey::PanelNoChanges), - Some(t(L10nKey::PanelNoChangesHint)), - cx, - ), - Some(Some(snap)) => { - let snap = Arc::clone(snap); - let untracked = snap.untracked_count(); - let focused = self.diff_overlay_focus(host.id(), &cwd).map(str::to_string); - let shown = snap.files.len().min(MAX_RENDERED_FILES); - let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); - for file in snap.files.iter().take(shown) { - let path = file.path.clone(); - let (added, removed) = (file.added, file.removed); - let selected = focused.as_deref() == Some(path.as_str()); - list = list.child( - h_flex() - .id(gpui::SharedString::from(format!("panel-change-{path}"))) - .items_center() - .gap(px(8.)) - .px(px(4.)) - .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 host_id = host.id(); - let cwd = cwd.clone(); - let path = path.clone(); - cx.listener(move |this, _, window, cx| { - this.toggle_diff_overlay_at( - host_id, - cwd.clone(), - Some(path.clone()), - window, - cx, - ); - }) - }) - .child(git_badge("M", cx.theme().muted_foreground, &mono)) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(rems(TEXT_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(path), - ) - .when(added > 0, |this| { - this.child( - div() - .flex_none() - .text_size(rems(META_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - }) - .when(removed > 0, |this| { - this.child( - div() - .flex_none() - .text_size(rems(META_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().danger) - .child(format!("−{removed}")), - ) - }), - ); - } - if snap.files.len() > shown { - let rest = snap.files.len() - shown; - list = list.child( - div() - .px(px(4.)) - .py(px(3.)) - .text_size(rems(META)) - .text_color(cx.theme().muted_foreground) - .child(t_plural(L10nKey::PanelMoreChangedFiles, rest, &[])), - ); - } - if untracked > 0 { - list = list.child( - h_flex() - .items_center() - .gap(px(8.)) - .px(px(4.)) - .py(px(3.)) - .child(git_badge( - "U", - cx.theme().muted_foreground.opacity(0.75), - &mono, - )) - .child( - div() - .text_size(rems(META)) - .text_color(cx.theme().muted_foreground) - .child(t_plural(L10nKey::PanelUntracked, untracked, &[])), - ), - ); - } - list.into_any_element() - } - }; - self.panel_scroll(inner, title) - } - - fn spawn_right_panel_diff( - &mut self, - host: crate::ui::host_ops::SharedHost, - cwd: PathBuf, - cx: &mut Context, - ) { - if self.right_panel.diff_pending.is_some() { - return; - } - self.right_panel.diff_pending = Some((host.id(), cwd.clone())); - self.spawn_shared_diff_probe(host, cwd, cx); - } - - pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { - if self.right_panel.diff_pending.is_some() { - return; - } - let Some((id, cwd)) = self.right_panel.diff_cwd.clone() else { - return; - }; - let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, id) else { - return; - }; - let Some(Some(snap)) = &self.right_panel.diff else { - return; - }; - let Some(status) = cx - .try_global::() - .and_then(|cache| cache.status_for(id, &cwd)) - else { - return; - }; - let stale = status.branch != snap.branch || (status.added, status.removed) != snap.totals(); - if stale { - self.spawn_right_panel_diff(host, cwd, cx); - } - } - fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let remote = self.remote_files_pane(window, cx); let host = remote.as_ref().map(|(_, host)| host.clone()); @@ -1058,10 +892,25 @@ impl Tty7App { } } +/// Width of the fixed cell a git status letter is centred in. +/// +/// Load-bearing beyond this function: `scm/panel.rs` gives its group-header +/// chevron box exactly this width so the group arrows and the status letters +/// stack into one vertical line down the right edge of the panel, and it keeps +/// its own `BADGE_W` in step. Changing it here without changing it there +/// breaks that column. +pub(crate) const BADGE_W: f32 = 14.; + +/// A single-letter git status marker in a fixed-width cell. +/// +/// Mono and SEMIBOLD so `M`, `A`, `D` and `U` all read as the same kind of +/// mark at a glance, and centred in a cell wide enough for the widest of them +/// at [`PANEL_TEXT_META`] — that is what makes a column of them line up +/// instead of drifting with the glyph widths. pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { div() .flex_none() - .w(px(14.)) + .w(px(BADGE_W)) .text_center() .text_size(rems(META_MONO)) .font_family(mono.clone()) @@ -1071,6 +920,14 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri .into_any_element() } +/// A small filled pill around a mono token — a pid, a port number. +/// +/// The padding and the radius are derived from the text size: at +/// [`PANEL_TEXT_META`] the line box is `round(10.5 × 1.618) = 17px`, so 1.5px +/// of vertical padding makes the pill 20px tall — one pixel more than the 19px +/// line of [`PANEL_TEXT`] beside it, which is what sets the height of a ports +/// row. Horizontal padding of 5px is about half an em of breathing room on +/// each side, and radius 4 is a fifth of the pill's height. pub(crate) fn info_chip( text: &str, bg: gpui::Hsla, diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs new file mode 100644 index 00000000..07f59a2a --- /dev/null +++ b/src/ui/scm/actions.rs @@ -0,0 +1,609 @@ +//! Where the source control actions and palette commands land. +//! +//! One `ScmIntent` match, so the four ways of asking for a verb — the action, +//! the key binding, the palette entry and the button on the row — cannot drift +//! into meaning different things. + +use gpui::{Context, PromptLevel, Window}; + +use tty7_core::core::git::ops::{Destructive, GitOp, PullMode}; +use tty7_core::core::git::status::HeadState; + +use crate::core::config::DiffViewMode; +use crate::ui::app::Tty7App; +use crate::ui::host_registry::HostRegistry; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::scm::state::RepoKey; + +/// One entry point for every source control verb. +/// +/// A single enum rather than fourteen methods: the actions, the palette and +/// the row buttons all want the same behaviour, and routing them through one +/// match is what keeps the three from drifting apart. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum ScmIntent { + Commit, + CommitAmend, + /// Commit, then send it on. Two operations rather than one, so the commit + /// still stands if the network half fails — and strictly in that order: + /// the send rides in the commit's [`ScmFollowUp`], because a push + /// dispatched alongside the commit resolves the branch tip whenever the + /// pool gets to it, and pushing the *old* tip reports success while + /// sending nothing. + CommitAndPush, + CommitAndSync, + StageAll, + UnstageAll, + DiscardAll, + Refresh, + Sync, + Push, + Pull, + Fetch, + CheckoutBranch, + CreateBranch, +} + +/// What to run once an operation has landed *successfully*. +/// +/// Compound verbs — commit-and-push, pull-then-push, discard-all's two halves +/// — are sequences, not bundles: the second operation only makes sense against +/// the repository the first one produced. Dispatching both into the worker +/// pool at once lets them race, so the second rides here and is started from +/// the first one's landing closure instead. A failed or cancelled first half +/// drops the follow-up. +#[derive(Clone, Debug)] +pub(crate) enum ScmFollowUp { + /// Push the current branch, re-reading the (by then updated) status. + Push, + /// Pull, then push — the whole sync sequence. + Sync, + /// One more operation, run without a second confirmation: the prompt that + /// approved the first half covered this one too. + Op(GitOp), +} + +impl Tty7App { + /// Fold the history section open or shut and remember it. + pub(crate) fn scm_toggle_graph(&mut self, cx: &mut Context) { + let next = !self.scm.graph.expanded; + self.scm.graph.expanded = next; + self.update_config(cx, |cfg| cfg.scm_graph_expanded = next); + cx.notify(); + } + + /// Flip the diff overlay between side-by-side and unified. + /// + /// Global rather than per-overlay, matching `diffEditor.renderSideBySide`: + /// someone who prefers unified prefers it for every file. + pub(crate) fn toggle_diff_view_mode(&mut self, cx: &mut Context) { + let next = match cx.global::().diff_view { + DiffViewMode::Split => DiffViewMode::Unified, + DiffViewMode::Unified => DiffViewMode::Split, + }; + self.update_config(cx, |cfg| cfg.diff_view = next); + cx.notify(); + } + + /// Run one operation against the panel's repository, asking first when it + /// can lose work. + /// + /// The gate lives here rather than in `run_git_op` because + /// [`GitOp::destructive`] is advice about what the user stands to lose, + /// and only a window can ask them. `window.prompt` is the project's one + /// confirmation mechanism — deleting a file in the tree already uses it — + /// so no modal component is introduced for this. + pub(crate) fn scm_op( + &mut self, + repo: RepoKey, + op: GitOp, + window: &mut Window, + cx: &mut Context, + ) { + self.scm_op_then(repo, op, None, window, cx); + } + + /// [`scm_op`], with something to run once this operation has succeeded. + /// Cancelling the confirmation drops the follow-up along with the op. + pub(crate) fn scm_op_then( + &mut self, + repo: RepoKey, + op: GitOp, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(host) = HostRegistry::get(cx, repo.host) else { + return; + }; + let Some(loss) = op.destructive() else { + self.run_git_op(host, repo.root, op, then, window, cx); + return; + }; + let answer = window.prompt( + PromptLevel::Warning, + &confirm_question(&op, loss), + None, + &[t(L10nKey::Cancel), confirm_verb(&op, loss)], + cx, + ); + cx.spawn_in(window, async move |app, cx| { + let Ok(1) = answer.await else { return }; + let _ = app.update_in(cx, |app, window, cx| { + app.run_git_op(host, repo.root, op, then, window, cx) + }); + }) + .detach(); + } + + pub(crate) fn run_scm_action( + &mut self, + intent: ScmIntent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repo) = self.scm.active_repo().cloned() else { + return; + }; + match intent { + ScmIntent::Refresh => { + self.scm_invalidate(repo.host, &repo.root, cx); + cx.notify(); + } + ScmIntent::StageAll => self.scm_op(repo, GitOp::StageAll, window, cx), + ScmIntent::UnstageAll => self.scm_op(repo, GitOp::UnstageAll, window, cx), + ScmIntent::DiscardAll => self.scm_discard_all(repo, window, cx), + ScmIntent::Commit => { + let amend = self.scm.amend; + self.scm_commit(repo, amend, None, window, cx); + } + ScmIntent::CommitAmend => self.scm_commit(repo, true, None, window, cx), + ScmIntent::CommitAndPush => { + let amend = self.scm.amend; + self.scm_commit(repo, amend, Some(ScmFollowUp::Push), window, cx); + } + ScmIntent::CommitAndSync => { + let amend = self.scm.amend; + self.scm_commit(repo, amend, Some(ScmFollowUp::Sync), window, cx); + } + ScmIntent::Sync => self.scm_sync(repo, window, cx), + ScmIntent::Push => self.scm_push(repo, false, window, cx), + ScmIntent::Pull => self.scm_op( + repo, + GitOp::Pull { + mode: PullMode::FfOnly, + }, + window, + cx, + ), + ScmIntent::Fetch => self.scm_op( + repo, + GitOp::Fetch { + remote: None, + prune: false, + }, + window, + cx, + ), + ScmIntent::CreateBranch => self.scm_begin_create_branch(window, cx), + // Checking out is a pick, not a verb: the switcher hangs off the + // branch name, which is where the list of branches already is. + ScmIntent::CheckoutBranch => {} + } + } + + /// Commit whatever the message box holds. + /// + /// The message comes from the box when it is the one on screen and from + /// the saved draft otherwise, so the key binding and the palette entry + /// commit the same text the user can see. + pub(crate) fn scm_commit( + &mut self, + repo: RepoKey, + amend: bool, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else { + return; + }; + let message = self.scm_message(&repo, cx); + let plan = crate::ui::scm::panel::commit_plan(&status, amend, &message); + if !plan.enabled { + gpui_component::WindowExt::push_notification( + window, + t(L10nKey::ScmNothingToCommit).to_string(), + cx, + ); + // The follow-up dies with the commit: "commit and push" with + // nothing to commit must not push whatever the branch holds. + return; + } + let all = crate::ui::scm::panel::commit_stages_everything(&status, amend); + self.scm.amend = false; + // `run_git_op` arms `scm.committing` when the commit is actually + // dispatched — after the amend confirmation, not before it — so a + // cancelled prompt leaves nothing armed. See `scm_commit_landed`. + self.scm_op_then( + repo, + GitOp::Commit { + message, + amend, + signoff: false, + no_verify: false, + all, + }, + then, + window, + cx, + ); + } + + /// What the commit box holds for a repository, whether or not it is the + /// one currently on screen. + fn scm_message(&self, repo: &RepoKey, cx: &gpui::App) -> String { + match (&self.scm.commit_input, &self.scm.commit_repo) { + (Some(input), Some(showing)) if showing == repo => input.read(cx).value().to_string(), + _ => self.scm.draft(repo).to_string(), + } + } + + /// Park everything, including the files git does not track yet. + /// + /// `-u`, because a stash that silently leaves new files behind is a stash + /// that did not do what "stash all" says. + pub(crate) fn scm_stash_all( + &mut self, + repo: RepoKey, + window: &mut Window, + cx: &mut Context, + ) { + let message = match self.scm_message(&repo, cx) { + m if m.trim().is_empty() => None, + m => Some(m), + }; + self.scm_op( + repo, + GitOp::Stash { + message, + include_untracked: true, + }, + window, + cx, + ); + } + + /// Throw away every change in the worktree: unstaged edits and untracked + /// files alike. + /// + /// Two operations, because git has no single command for it — + /// `checkout --` cannot touch a file it has never heard of, and `clean` + /// cannot touch one it has. One confirmation and one sequence, though: + /// the second half rides in the first one's [`ScmFollowUp`], so the user + /// answers a single dialog and the two gits never race each other. + /// + /// Only *unstaged* paths go to `checkout --`: it restores from the index, + /// so a staged edit would survive it anyway, and a staged deletion — a + /// path in neither index nor worktree — would make git reject the whole + /// batch as an unmatched pathspec. What is staged stays staged, which is + /// also what the button's own group implies. + fn scm_discard_all(&mut self, repo: RepoKey, window: &mut Window, cx: &mut Context) { + let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else { + return; + }; + let (first, second) = match &discard_all_ops(&status)[..] { + [] => return, + [one] => (one.clone(), None), + [a, b, ..] => (a.clone(), Some(b.clone())), + }; + let Some(host) = HostRegistry::get(cx, repo.host) else { + return; + }; + // Its own prompt rather than `scm_op_then`'s: that one names the file + // when an op carries a single path, and "Discard changes to a.rs?" + // would be the wrong question for a click that also sweeps the + // untracked files. + let answer = window.prompt( + PromptLevel::Warning, + &t(L10nKey::ScmDiscardAllConfirm).to_string(), + None, + &[t(L10nKey::Cancel), t(L10nKey::ScmDiscard)], + cx, + ); + cx.spawn_in(window, async move |app, cx| { + let Ok(1) = answer.await else { return }; + let _ = app.update_in(cx, |app, window, cx| { + app.run_git_op( + host, + repo.root, + first, + second.map(ScmFollowUp::Op), + window, + cx, + ) + }); + }) + .detach(); + } + + /// Push the current branch to its upstream, or publish it if it has none. + pub(crate) fn scm_push( + &mut self, + repo: RepoKey, + force_with_lease: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else { + return; + }; + let HeadState::Branch { name, .. } = &status.head else { + // A detached HEAD has no branch to push, and pushing a bare sha + // needs a refspec the panel has no way to ask for. + return; + }; + let (remote, branch) = match status.upstream.as_deref().and_then(split_upstream) { + Some((remote, branch)) => (remote.to_string(), branch.to_string()), + None => ("origin".to_string(), name.clone()), + }; + let set_upstream = status.upstream.is_none(); + self.scm_op( + repo, + GitOp::Push { + remote, + branch, + set_upstream, + force_with_lease, + }, + window, + cx, + ); + } + + /// Pull then push, which is what "sync" means everywhere else — and + /// strictly in that order: the push rides in the pull's [`ScmFollowUp`], + /// because a push racing the pull it was waiting for reads the pre-pull + /// tip and earns a non-fast-forward rejection from the very sync that + /// was fixing it. A failed pull stops the sequence. + /// + /// A branch with no upstream has nothing to pull, so sync is a publish. + pub(crate) fn scm_sync(&mut self, repo: RepoKey, window: &mut Window, cx: &mut Context) { + let has_upstream = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) + .is_some_and(|s| s.upstream.is_some()); + if has_upstream { + self.scm_op_then( + repo, + GitOp::Pull { + mode: PullMode::FfOnly, + }, + Some(ScmFollowUp::Push), + window, + cx, + ); + } else { + self.scm_push(repo, false, window, cx); + } + } + + /// Run the second half of a compound verb, from the first half's landing. + pub(crate) fn scm_follow_up( + &mut self, + host: tty7_core::host::HostId, + root: std::path::PathBuf, + follow: ScmFollowUp, + window: &mut Window, + cx: &mut Context, + ) { + let repo = RepoKey { host, root }; + match follow { + ScmFollowUp::Push => self.scm_push(repo, false, window, cx), + ScmFollowUp::Sync => self.scm_sync(repo, window, cx), + ScmFollowUp::Op(op) => { + let Some(shared) = HostRegistry::get(cx, repo.host) else { + return; + }; + self.run_git_op(shared, repo.root, op, None, window, cx); + } + } + } + + /// A dispatched commit came back with an error: disarm the latch that + /// would otherwise clear the message box on the next unrelated HEAD move. + /// The message itself stays where the user can see it. + pub(crate) fn scm_commit_failed( + &mut self, + host: tty7_core::host::HostId, + root: &std::path::Path, + ) { + if self + .scm + .committing + .as_ref() + .is_some_and(|(r, _, _)| r.host == host && r.root == root) + { + self.scm.committing = None; + } + } +} + +/// What "discard all" actually runs, in order. Pure so a test can hold it up +/// against a status without a window. +fn discard_all_ops(status: &tty7_core::core::git::status::WorkingTreeStatus) -> Vec { + let unstaged: Vec<_> = status + .unstaged() + .filter(|e| e.path.pathspec().is_some()) + .map(|e| e.path.clone()) + .collect(); + let untracked: Vec<_> = status + .untracked() + .filter(|e| e.path.pathspec().is_some()) + .map(|e| e.path.clone()) + .collect(); + let mut ops = Vec::new(); + if !unstaged.is_empty() { + ops.push(GitOp::DiscardWorktree { paths: unstaged }); + } + if !untracked.is_empty() { + let directories = untracked.iter().any(|p| p.as_str().ends_with('/')); + ops.push(GitOp::DiscardUntracked { + paths: untracked, + directories, + }); + } + ops +} + +/// `origin/main` → `("origin", "main")`. +/// +/// The first component is the remote: a branch name may contain slashes, a +/// remote name may not. +pub(crate) fn split_upstream(upstream: &str) -> Option<(&str, &str)> { + let (remote, branch) = upstream.split_once('/')?; + (!remote.is_empty() && !branch.is_empty()).then_some((remote, branch)) +} + +/// The question a destructive operation has to answer before it runs. +fn confirm_question(op: &GitOp, loss: Destructive) -> String { + // Its own question, not the discard one: a hard reset to an older commit + // drops commits off the branch, and a dialog that says "Discard every + // change in this repository?" never mentions the part that hurts. + if matches!(op, GitOp::Reset { .. }) { + return t(L10nKey::ScmResetHardConfirm).to_string(); + } + match loss { + Destructive::RewritesHistory => t(L10nKey::ScmAmendConfirm).to_string(), + // One file gets named; a whole group does not, because a list of two + // hundred paths in a system dialog says less than the count does. + _ => match op.paths() { + [only] => t_fmt(L10nKey::ScmDiscardConfirm, &[("path", only.as_str())]), + _ => t(L10nKey::ScmDiscardAllConfirm).to_string(), + }, + } +} + +fn confirm_verb(op: &GitOp, loss: Destructive) -> &'static str { + if matches!(op, GitOp::Reset { .. }) { + return t(L10nKey::ScmReset); + } + match loss { + Destructive::RewritesHistory => t(L10nKey::ScmAmendLastCommit), + _ => t(L10nKey::ScmDiscard), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tty7_core::core::git::status::{ + ChangeCode, EntryKind, HeadState, RepoPath, StatusEntry, WorkingTreeStatus, + }; + + fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry { + StatusEntry { + path: RepoPath::from_bytes(path.as_bytes()), + orig_path: None, + index, + worktree, + kind, + submodule: None, + rename_score: None, + conflict: None, + } + } + + fn status_with(entries: Vec) -> WorkingTreeStatus { + WorkingTreeStatus { + root: std::path::PathBuf::from("/repo"), + home: std::path::PathBuf::from("/repo"), + head: HeadState::Branch { + name: "main".into(), + oid: "0".repeat(40), + }, + upstream: None, + ahead_behind: None, + total_entries: entries.len(), + entries, + truncated: false, + stash_count: 0, + operation: None, + prefilled_message: None, + } + } + + /// `checkout --` restores from the *index*: a staged-only path either + /// survives it (staged edit) or — a staged deletion, in neither index nor + /// worktree — makes git reject the whole batch as an unmatched pathspec, + /// taking every real discard down with it. Only unstaged paths go in. + #[test] + fn discard_all_sends_only_unstaged_paths_to_checkout() { + let status = status_with(vec![ + // Staged edit, clean worktree: not `checkout --`'s business. + entry( + "staged.rs", + ChangeCode::Modified, + ChangeCode::None, + EntryKind::Tracked, + ), + // Staged deletion: the pathspec that used to sink the batch. + entry( + "deleted.rs", + ChangeCode::Deleted, + ChangeCode::None, + EntryKind::Tracked, + ), + // Staged and edited again: the worktree half is discardable. + entry( + "both.rs", + ChangeCode::Modified, + ChangeCode::Modified, + EntryKind::Tracked, + ), + entry( + "edited.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + ), + entry( + "new.rs", + ChangeCode::None, + ChangeCode::None, + EntryKind::Untracked, + ), + ]); + let ops = discard_all_ops(&status); + assert_eq!(ops.len(), 2, "one checkout batch, one clean batch"); + match &ops[0] { + GitOp::DiscardWorktree { paths } => { + let names: Vec<_> = paths.iter().map(|p| p.as_str()).collect(); + assert_eq!(names, vec!["both.rs", "edited.rs"]); + } + other => panic!("expected DiscardWorktree first, got {:?}", other.label()), + } + match &ops[1] { + GitOp::DiscardUntracked { paths, directories } => { + let names: Vec<_> = paths.iter().map(|p| p.as_str()).collect(); + assert_eq!(names, vec!["new.rs"]); + assert!(!directories); + } + other => panic!("expected DiscardUntracked second, got {:?}", other.label()), + } + + // Nothing to discard means nothing to run — and no prompt to answer. + assert!(discard_all_ops(&status_with(Vec::new())).is_empty()); + } + + #[test] + fn an_upstream_splits_on_its_first_slash_only() { + assert_eq!(split_upstream("origin/main"), Some(("origin", "main"))); + // Branch names carry slashes; remote names cannot. + assert_eq!( + split_upstream("origin/feature/auth-retry"), + Some(("origin", "feature/auth-retry")) + ); + assert_eq!(split_upstream("main"), None); + assert_eq!(split_upstream("/main"), None); + assert_eq!(split_upstream("origin/"), None); + } +} diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs new file mode 100644 index 00000000..d10e3a19 --- /dev/null +++ b/src/ui/scm/detail.rs @@ -0,0 +1,1273 @@ +//! One commit, in the panel's own body. +//! +//! Replacing the body rather than opening a third kind of container: the +//! panel already knows how to draw a list of changed files, and the only +//! things a commit adds above it are its message and who wrote it. The file +//! rows are the same rows, minus the buttons — nothing here should be able to +//! drift from what the working tree shows. +//! +//! 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, how many lines moved, and +//! the files. +//! +//! What it is *not* is a container of its own. It is drawn flush with the +//! panel, on the panel's fill, in the panel's type scale — the same dense, +//! low-chrome language as every other body the right panel shows. A +//! second-level view announces itself by the way back at the top of it and by +//! being the only thing on screen; it does not need a raised card, a larger +//! ramp or filled tokens to say so, and an earlier round that gave it all +//! three read as a foreign design pasted into the app. A later round tried the +//! ramp on its own — 14/12/11.5 through the whole panel — and it read the same +//! way: too big for a 260px column, and too loud beside the graph. The sizes +//! below are the panel's own, and this view has no business being a step above +//! them. + +use std::sync::Arc; + +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. +/// +/// Both numbers are a 12px row's. gpui leads a plain `div` at phi, so the row's +/// mono name occupies `round(12 × 1.618) = 19px`, and 24 gives that line 2.5px +/// of air on each side — dense, which is what a 260px column of paths wants. +/// The text lands on `CONTENT_INSET` whatever `ROW_INSET` is, since the list +/// subtracts it outside the row and the row adds it back inside. +/// +/// Both have to equal `panel.rs`'s pair. That file carries the same two +/// constants for the same reason, and a reader who opens a commit must not +/// feel the pitch change under them. +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; + +/// The panel's type ramp, named rather than spelled out at each of its dozen +/// uses. These are not this view's sizes to choose: they are the steps the +/// right panel runs on, and the whole point of naming them here is that a +/// future edit changes a constant instead of drifting one line of the body off +/// the ramp. +/// +/// 12px is body text and the loudest thing on screen — the subject, and the +/// way back, which is the same size worn quietly. 11px is everything that +/// qualifies it: the byline, the message body, the parents' label, the file +/// count, the waiting notes. One point of difference is all a qualifier needs +/// in a column this narrow; the separation is carried by weight and colour, +/// not by the gap. 10.5px mono is the token size — sha-like strings, ref +/// chips, the diff counts, and `git_badge`'s status letter. That one is barely +/// a choice made here: `git_badge` and `info_chip` set their own mono at 10.5, +/// and an object id has to read as the same kind of thing in this view as it +/// does in the graph and on a file row. +/// +/// Emphasis in this panel is weight and colour rather than size, and a fill +/// only where it carries a meaning of its own — HEAD, and a tag. The subject is +/// a step up in weight against the full foreground while everything under it is +/// muted, and that separation is all it needs; it is what a card was briefly +/// and wrongly asked to do, and what a larger ramp was later asked to do after +/// that. Both were turned down. Nothing in this view is bigger than the +/// working tree's rows are. +/// +/// `right_panel.rs` is where the panel's own steps live, and these are that +/// ramp under local names that say what each step does *here* — subject, +/// qualifier, token. If the two ever disagree, that file is the one that is +/// right. +/// +/// The changed-file rows deliberately do not read these. They spell 12 and 11 +/// out because they have to stay pixel-identical to `scm_file_row` in +/// `panel.rs`, and a constant shared with the prose above would let a change +/// here silently break that. +const SUBJECT_SIZE: f32 = 12.; +const SECONDARY_SIZE: f32 = 11.; +const TOKEN_SIZE: f32 = 10.5; + +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 Window, + cx: &mut Context, + ) -> Option { + // 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; + // No surface, no margin: this is the panel's body while a commit is + // open, and it starts where every other panel body starts. + // + // Each section insets itself by `CONTENT_INSET` rather than sharing one + // on the column, because the rows that want a hover fill lay themselves + // out a `ROW_INSET` short of it so the fill is wider than the text, and + // an outer inset would have to be undone by every one of them. + 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(SECONDARY_SIZE)) + .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)); + } + match files { + Some(files) => open.files = Some(Arc::new(files)), + // A failed read is not an empty commit — see + // `CommitDetailView::files_failed`. + None => open.files_failed = true, + } + 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. + /// + /// Both halves of the row are chrome and are drawn as chrome: muted, no + /// resting fill, the hover doing all the work of saying they are hit + /// targets. The loudest text in this view has to be the subject — the row + /// above it is a way out and a string to copy, and neither is what the + /// reader came to read. The oid is set in the same mono at the same token + /// size as the parent links below, so the two read as the same kind of + /// thing. + fn detail_header_row( + &self, + detail: &CommitDetailView, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let oid = detail.oid.clone(); + let hover_bg = gpui::rgb(panel_surface(cx).hover); + let muted = cx.theme().muted_foreground; + 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(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(hover_bg)) + .on_click(cx.listener(|this, _, _window, cx| this.close_commit_detail(cx))) + // `small()`, which is 14px of glyph beside a 12px label — + // an icon needs a little more box than the text it labels + // to read as the same size, and it is the width of + // `git_badge`'s cell as well. + .child(Icon::new(IconName::ChevronLeft).small().text_color(muted)) + .child( + div() + .text_size(px(SUBJECT_SIZE)) + .text_color(muted) + .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(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(hover_bg)) + .text_size(px(TOKEN_SIZE)) + .text_color(muted) + .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. It carries the + // weight and the full foreground while everything under it is + // muted, and that is the whole of its emphasis — it sits on + // the same 12px step as the file rows below it. + div() + .text_size(px(SUBJECT_SIZE)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .line_clamp(SUBJECT_LINES) + .child(SharedString::from(commit.summary.clone())), + ) + .child( + div() + .text_size(px(SECONDARY_SIZE)) + .text_color(cx.theme().muted_foreground) + .child(byline(commit, now_unix())), + ) + .when(!body.is_empty(), |this| { + this.child( + // The secondary size — the same one the byline above it + // and the parents below it are set in. A size of its own + // bought nothing and cost the reader a fourth step in a + // view eight lines tall. + div() + .pt(px(2.)) + .text_size(px(SECONDARY_SIZE)) + .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(SECONDARY_SIZE)) + .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. + /// + /// Exactly two of them get a fill, and they are the two that mean + /// something. HEAD is where you are, washed in `accent` under the full + /// foreground — one emphasised token on the row. A tag is yellow because a + /// tag is yellow everywhere in git. Everything else — the other local + /// branches, every remote-tracking ref — is a bare muted span: no fill, and + /// therefore no padding either, because padding exists to hold text off a + /// background and there is no background to hold it off. A ref name is + /// already a word with spaces around it; drawing a box around every one of + /// them turns a list of names into a wall of blocks, which is what the + /// panel's language is trying not to be. + /// + /// `theme.accent` is a neutral surface tint in tty7 rather than the brand + /// colour, which is exactly why 0.28 of it works: it is a raised patch, not + /// a wash of hue, and the foreground stays legible on it. Do not substitute + /// `theme.ring` here and then have to drop the opacity to compensate. + /// + /// What this must never go back to is the bug that predated all of it: the + /// fallback arm painted `theme.accent` at *full* opacity under muted text, + /// which made `origin/main` louder than the branch you were actually on and + /// left HEAD looking like the footnote. + 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() + // Six, not four: most of these are bare words now, and words need a + // little more air between them than chips whose fills already say + // where one ends and the next begins. + .gap(px(6.)) + .px(px(CONTENT_INSET)) + .pb(px(6.)); + for deco in &commit.refs { + row = row.child(match deco.kind { + RefKind::Tag => info_chip(&deco.short, warning.opacity(0.16), warning, mono), + _ if deco.is_head => info_chip(&deco.short, accent.opacity(0.28), fg, mono), + _ => ref_span(&deco.short, muted, 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. + /// + /// `theme.info` and nothing else at rest — the panel's link ink, the same + /// one the "show more" fold uses a few lines above. A filled pill would + /// make the parents a second block competing with the ref chips, and this + /// is a link, not a state. The hover fill is what says the oid is a target, + /// and it is the same fill, radius and inset the header's two affordances + /// use. The oids themselves are token-sized mono, matching the sha in the + /// header so that every object id in this view is one recognisable shape. + fn detail_parents( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> Option { + if commit.parents.is_empty() { + return None; + } + let hover_bg = gpui::rgb(panel_surface(cx).hover); + let (muted, link) = { + let theme = cx.theme(); + (theme.muted_foreground, theme.info) + }; + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap(px(6.)) + .px(px(CONTENT_INSET)) + .pb(px(4.)) + .child( + div() + .text_size(px(SECONDARY_SIZE)) + .text_color(muted) + .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(4.)) + .py(px(1.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(hover_bg)) + .text_size(px(TOKEN_SIZE)) + .font_family(mono.clone()) + .text_color(link) + .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()) + } + + /// The summary line and the rows under it. + /// + /// While the read is out there is no summary line at all. The count used to + /// come from `unwrap_or_default()` and so said "no files changed" for as + /// long as it took git to answer, which is a claim rather than a wait. + fn detail_files( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let Some(files) = detail.files.clone() else { + let note = if detail.files_failed { + L10nKey::ScmDetailFilesFailed + } else { + L10nKey::PanelLoading + }; + return self.detail_note(t(note).to_string(), cx); + }; + let list = v_flex().child(self.detail_summary(&files, mono, cx)); + // 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() + } + + /// `12 files changed +340 −118`. + /// + /// How much the commit is, in one line: the count in words and the size in + /// numbers. The counts are the only place in this view where green and red + /// appear, which is what lets them be read without a legend — and they are + /// mono so the two columns of digits line up against the counts the diff + /// overlay shows for the same commit, where the reader is going next. + /// + /// `−` is U+2212, not a hyphen, matching every other count and gutter mark + /// in the diff views: the ASCII one sits too high and too short beside a + /// `+` of the same size. + /// + /// Still not `panel_subtitle`: that helper uppercases its label and puts + /// anything in its trailing slot hard against the right edge, because the + /// slot was built for a button. Both are wrong here. "3 FILES CHANGED" is + /// a heading's voice and this is a sentence about the commit, and the + /// counts are not a control off in the corner — they qualify the words and + /// have to sit next to them, which is the one thing the layout round got + /// right and the user asked to keep. + /// + /// What the helper *is* copied on is its frame: the hairline and the six + /// above it, so the file list starts on exactly the line the working tree's + /// does. A rule is how this panel divides sections; the round that replaced + /// it with a raised card is the round being undone. + /// + /// The two paddings are that frame re-derived rather than copied, because + /// the tallest line in each block is a different size. gpui leads a plain + /// `div` at phi: the helper's 10.5px uppercase label measures + /// `round(10.5 × 1.618) = 17px`, and the tallest thing in this row is the + /// 11px file count at `round(11 × 1.618) = 18`. The helper's block is + /// `6 + 1 + 12 + 17 + 4 = 40px` tall, so this one has 15px of padding to + /// spend instead of 16 — half a pixel off each side, which keeps the total + /// at 40 *and* puts both lines' optical centre 27.5px below the top of the + /// margin, so nothing shifts when the reader opens a commit. Change either + /// side's type and this has to be worked out again on both, or one list + /// quietly starts a pixel or two below the other and nobody can see why. + fn detail_summary( + &self, + files: &[CommitFile], + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let (muted, border) = (theme.muted_foreground, theme.border); + let (added_ink, removed_ink) = (theme.success, theme.danger); + let counts = diff_totals(files); + h_flex() + .items_center() + .gap(px(6.)) + .mt(px(6.)) + .border_t_1() + .border_color(border) + .px(px(CONTENT_INSET)) + .pt(px(11.5)) + .pb(px(3.5)) + .child( + div() + .text_size(px(SECONDARY_SIZE)) + .text_color(muted) + .child(t_plural(L10nKey::ScmFilesChanged, files.len(), &[])), + ) + .when_some(counts, |this, (added, removed)| { + this.child( + h_flex() + .items_center() + .gap(px(5.)) + .text_size(px(TOKEN_SIZE)) + .font_family(mono.clone()) + .child(div().text_color(added_ink).child(format!("+{added}"))) + .child(div().text_color(removed_ink).child(format!("−{removed}"))), + ) + }) + .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 = panel_surface(cx); + 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, source) + == 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() + // 12 and, below, 11: written out rather than taken from + // `SUBJECT_SIZE` and `SECONDARY_SIZE`, which happen to + // hold the same two numbers. The prose above this list is + // free to move off the ramp one day; a row is not, because + // it has to stay pixel-identical to `scm_file_row`, and + // sharing a constant with the prose is exactly how that + // would break without anybody touching this function. + // + // Its counterpart spells the same two out for the same + // reason, and says so in a comment pointing back here. + // They are the SCM panel's own 12/11px steps (the panel + // has not moved onto the interface font scale yet); a move + // of that ramp has to be carried into both rows by hand, + // and nothing but these two comments says so. + .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(SECONDARY_SIZE)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(text) + .into_any_element() + } +} + +/// The surface every hover and selection in this view is computed against. +/// +/// The sidebar's, because the right panel is the sidebar and this view paints +/// nothing under itself. A fill derived from `window` would be a step off a +/// base that is not there, and `list_hover` is the popover's. +fn panel_surface(cx: &gpui::App) -> crate::ui::presets::Surface { + cx.global::().sidebar +} + +/// One unemphasised ref, as bare text. +/// +/// The counterpart to `info_chip` for the arm that has no fill: same mono, same +/// size, no padding and no radius, so a row of ordinary refs reads as a row of +/// words rather than a row of empty boxes. `flex_none` because the row wraps +/// and a ref name must break between names, never inside one. +fn ref_span(text: &str, ink: gpui::Hsla, mono: &SharedString) -> AnyElement { + div() + .flex_none() + .text_size(px(TOKEN_SIZE)) + .font_family(mono.clone()) + .text_color(ink) + .child(text.to_string()) + .into_any_element() +} + +/// The commit's line delta, summed over the files git was able to count. +/// +/// The numbers are already in hand: `commit_files` joins `--numstat` against +/// `--name-status`, so every [`CommitFile`] arrives carrying its own `added` +/// and `removed`. Nothing extra is read to draw this line. +/// +/// `None` means there is nothing worth printing, which is two cases wearing +/// one answer. A binary file has no counts at all — `--numstat` prints `-` for +/// both columns and the fields come through as [`None`] — so a commit that +/// only touched binaries sums to zero out of zero. A pure rename does have +/// counts, and they are `0` and `0`. Either way `+0 −0` is a measurement of +/// nothing, and a line of type that answers a question nobody asked; the file +/// rows already say what happened. +/// +/// Summed with `saturating_add` rather than `sum()`, which panics on overflow +/// in a debug build. This runs in `render`, and a repository that manages four +/// billion added lines in one commit should get a wrong number, not a crash. +pub(crate) fn diff_totals(files: &[CommitFile]) -> Option<(u32, u32)> { + let fold = |pick: fn(&CommitFile) -> Option| { + files + .iter() + .filter_map(pick) + .fold(0u32, |acc, n| acc.saturating_add(n)) + }; + let (added, removed) = (fold(|f| f.added), fold(|f| f.removed)); + (added > 0 || removed > 0).then_some((added, removed)) +} + +/// `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::diff::FileStatus; + 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(""), ""); + } + + fn file(path: &str, status: FileStatus, counts: Option<(u32, u32)>) -> CommitFile { + CommitFile { + path: path.into(), + orig_path: None, + status, + added: counts.map(|c| c.0), + removed: counts.map(|c| c.1), + binary: counts.is_none(), + } + } + + #[test] + fn the_summary_adds_up_every_file_git_could_count() { + let files = [ + file("a.rs", FileStatus::Modified, Some((10, 4))), + file("b.rs", FileStatus::Added, Some((2, 0))), + file("c.rs", FileStatus::Deleted, Some((0, 8))), + ]; + assert_eq!(diff_totals(&files), Some((12, 12))); + } + + /// The two shapes of "there is nothing to print", which have to come back + /// as the same answer even though git spells them differently: `-` for a + /// binary, and a real pair of zeroes for a rename. + #[test] + fn a_commit_with_nothing_countable_gets_no_counts_rather_than_zeroes() { + let binary = [file("logo.png", FileStatus::Modified, None)]; + assert_eq!( + diff_totals(&binary), + None, + "`--numstat` printed `-`, so there is no number to show" + ); + + let renamed = [file("new.rs", FileStatus::Renamed, Some((0, 0)))]; + assert_eq!( + diff_totals(&renamed), + None, + "a pure rename is counted, and what it counts to is nothing" + ); + + assert_eq!(diff_totals(&[]), None, "and neither is an empty list"); + } + + /// A binary alongside real edits must not swallow them, and must not be + /// counted as a zero that drags the total down either — it simply is not + /// part of the sum. + #[test] + fn an_uncountable_file_drops_out_of_a_sum_that_still_has_something_in_it() { + let mixed = [ + file("logo.png", FileStatus::Added, None), + file("main.rs", FileStatus::Modified, Some((3, 1))), + ]; + assert_eq!(diff_totals(&mixed), Some((3, 1))); + } + + /// `render` calls this, so an absurd repository has to give a wrong number + /// rather than take the frame down with it. + #[test] + fn a_total_past_what_a_u32_holds_saturates_instead_of_panicking() { + let files = [ + file("a.rs", FileStatus::Modified, Some((u32::MAX, 1))), + file("b.rs", FileStatus::Modified, Some((7, u32::MAX))), + ]; + assert_eq!(diff_totals(&files), Some((u32::MAX, u32::MAX))); + } +} + +/// 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. + /// + /// HEAD also carries a tag and a second branch, so that a frame drawn over + /// it exercises all three arms of `detail_refs` — the filled HEAD chip, the + /// filled tag chip and the bare `ref_span` — rather than only the one the + /// current branch happens to take. The fallback arm is where the ordinary + /// refs used to be painted louder than HEAD, so it is the arm most worth + /// putting through layout and paint. + 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", + ], + ); + git(&root, &["branch", "sidequest"]); + git(&root, &["tag", "v1.0.0"]); + 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"); + + // The three arms of `detail_refs`, so the frame drawn below is + // known to have gone through all of them and not just the first. + let refs = &commit.refs; + assert!(refs.iter().any(|r| r.is_head), "the checked-out branch"); + assert!( + refs.iter().any(|r| r.kind == RefKind::Tag), + "the tag: {refs:?}" + ); + assert!( + refs.iter().any(|r| !r.is_head && r.kind != RefKind::Tag), + "and a plain ref, which is the arm drawn without a fill: {refs:?}" + ); + }); + 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" + ); + + // The summary's counts come off the same list, with nothing else read + // for them. Rename detection is a git config away from changing what + // the individual rows say, so this asserts the shape rather than the + // arithmetic: two files of one line each were added, so there is a + // number to print and it is not zero. + app.update_in(&mut vcx, |app, _, _| { + let files = app.scm.detail.as_ref().unwrap().files.clone().unwrap(); + let (added, removed) = diff_totals(&files).expect("a text commit has counts"); + assert!( + added >= 2, + "the added lines were summed: +{added} −{removed}" + ); + }); + + // 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/graph.rs b/src/ui/scm/graph.rs new file mode 100644 index 00000000..52e3c097 --- /dev/null +++ b/src/ui/scm/graph.rs @@ -0,0 +1,2293 @@ +//! The history section at the foot of the panel. +//! +//! Its job is shape first and text second. 260px leaves room for roughly 26 +//! characters beside the lanes, and this repository's commit subjects run to a +//! median of 64 — so what a reader gets here is where the branches are, where +//! they merged, which refs sit where, and how recently anything moved. Reading +//! a whole message is the commit detail view's job, one click away. +//! +//! # What size everything is +//! +//! Three, and no more: 12px for the commit subject and for the +//! conventional-commit type in front of it, which are one run of text and are +//! told apart by tone rather than by size; 11px for the section's own heading +//! and for the "load more" control; and 10.5px for the annotations — the +//! relative age, the commit count, the ref chip. A sidebar list is dense by +//! design, and the graph is the densest thing in this panel. +//! +//! That is also VS Code's own reading of a sidebar graph, and it is why the +//! conventional-commit prefix is cut down to its type and demoted to muted ink +//! rather than left to eat half the line. The lane gutter itself never folds +//! away: the lanes are the graph, and a graph without them is just a list. +//! +//! The section is not a surface of its own. It sits flush on the panel's +//! `theme.sidebar` fill and is separated from the file list above it by a +//! hairline, the way every other band of this panel is. The only colour that +//! gets to be saturated here is a lane's, and the lanes stay in the gutter, +//! below the text; the rows themselves are ink, muted ink and the sidebar's own +//! neutral hover and selection fills. +//! +//! # How it is drawn +//! +//! One `canvas` covering the whole list, absolutely positioned over ordinary +//! interactive rows — never one canvas per row. Every `PrimitiveBatch::Paths` +//! gpui emits ends the current encoder, opens a render pass, clears a +//! drawable-sized intermediate texture, rasterises, resolves MSAA and +//! composites back; forty visible rows would mean forty of those per frame. +//! +//! Being on top costs nothing in event terms: `Canvas::id` returns `None` and +//! it implements no interactivity, so it registers no hitbox in prepaint. The +//! rows underneath keep gpui's native hover, click, context menu and +//! scroll-into-view. This was measured, not assumed — see the G7·0 spike commit. + +use std::cell::Cell as StdCell; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{ + AnyElement, BorderStyle, Bounds, Context, Corners, Edges, Focusable as _, Hsla, MouseButton, + MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Window, canvas, div, fill, point, + prelude::*, px, quad, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use tty7_core::core::git::log::{ + Commit, CommitPage, Edge, GRAPH_PAGE, GraphRow, GraphScope, Lane, MAX_GRAPH_COMMITS, RefDeco, + RefKind, +}; +use tty7_core::core::git::ops::{GitOp, ResetMode}; + +use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::presets::{ActiveLanes, LANE_SLOTS, Lanes}; +use crate::ui::right_panel::info_chip; +use crate::ui::scm::path::{elide_middle, relative_time}; +use crate::ui::scm::state::RepoKey; + +/// One commit per row. 20px rather than the file list's 24: a graph row has no +/// icon column, and the vertical pitch wants to stay close to the lane pitch or +/// the diagonal of a merge reads as a much shallower angle than it is. +/// +/// It also has to contain the line the subject is set on — gpui leads text at +/// phi, so 12px occupies `round(12 × 1.618) = 19px`, and 20 is the first even +/// pitch above it. +const GRAPH_ROW_H: f32 = 20.; +/// Rows materialized above and below the visible band, so a fast scroll never +/// outruns the window into blank space, and the "load more" band — laid out +/// one row past the window's end — stays below the fold until it is real. +const GRAPH_WINDOW_MARGIN: usize = 4; + +/// Header of the section itself: fold, title, count, filter tile, scope picker. +/// +/// The truth, not a wish: the row sets this height explicitly and the tallest +/// thing inside it is the `GRAPH_TILE` square, so 24 is what a reader measures +/// — the 18px tile plus 3px of air above and below it. The resize constants +/// below are counted against it, which is why it has to stay honest. +const GRAPH_HEADER_H: f32 = 24.; + +/// The header's controls: the filter tile and the scope picker beside it. +/// +/// An 18px square with an 11px glyph, sized against the 11px title beside it: +/// two thirds of the box, which is the fill that keeps an icon from rattling +/// around inside its tile. +const GRAPH_TILE: f32 = 18.; +const GRAPH_TILE_GLYPH: f32 = 11.; + +/// Horizontal distance between lane centres. +const GRAPH_LANE_W: f32 = 12.; + +/// Inset before the first lane centre, and the gap between the gutter and the +/// text column. +const GRAPH_PAD_L: f32 = 6.; +const GRAPH_PAD_R: f32 = 6.; + +/// An ordinary node is a 3px disc and a lane line is 1.5px wide: the dot is +/// 30% of the row's height, which is enough to read as a bead on a string +/// without closing the gap to the row above. Merges and roots are drawn from +/// the same radius rather than from a second vocabulary. +const GRAPH_DOT_R: f32 = 3.; +const GRAPH_LINE_W: f32 = 1.5; + +/// Most of the panel's width belongs to the message. Thirty percent is what +/// leaves five lanes at the 260px default and still keeps a readable column. +const GRAPH_GUTTER_SHARE: f32 = 0.30; + +/// Lanes are capped by what the panel can show, never by what history did. +const GRAPH_MIN_LANES: usize = 3; +const GRAPH_MAX_LANES: usize = LANE_SLOTS; + +/// The cap can never exceed the palette, or two columns on screen would come +/// out the same colour and the whole point of colouring by lane is lost. +const _: () = assert!(GRAPH_MAX_LANES <= LANE_SLOTS); + +/// Resting height of the history section and the range the divider drags it +/// through. The ceiling is a share of the window rather than a constant: the +/// file list has to keep a usable part of a short one. +/// +/// Both of the fixed ones are counted in rows against the header: what they +/// mean is a number of commits, not a number of pixels. 220 is `24 + 9.8 × 20` +/// — nine commits and most of a tenth, and the fraction is deliberate, because +/// a row cut by the bottom edge is the only honest way a fixed-height list says +/// there is more below it. 88 is `24 + 3.2 × 20`, three and a bit, which is the +/// least that still looks like history rather than like a mistake. +const GRAPH_H_DEFAULT: f32 = 220.; +const GRAPH_H_MIN: f32 = 88.; +const GRAPH_H_MAX_RATIO: f32 = 0.65; + +/// The divider's grab area, matching `RESIZE_HANDLE_WIDTH` on the other axis. +const GRAPH_HANDLE_H: f32 = 6.; + +/// Ref chips are the widest optional thing on a row, so they get a hard cap +/// and lose their middle rather than the message losing its column. +const GRAPH_REF_CHARS: usize = 14; + +/// How many lanes fit, given the panel's width. +/// +/// A pure projection over the width, deliberately: dragging the panel narrower +/// must not re-run the layout pass or renumber a colour. +fn max_lanes(panel_w: f32) -> usize { + // The share buys the whole gutter, insets included — budgeting only the + // lane strip would overrun it by a lane at every width. + let fit = ((panel_w * GRAPH_GUTTER_SHARE - GRAPH_PAD_L - GRAPH_PAD_R) / GRAPH_LANE_W).floor(); + if !fit.is_finite() { + return GRAPH_MIN_LANES; + } + (fit as usize).clamp(GRAPH_MIN_LANES, GRAPH_MAX_LANES) +} + +/// Fold a true lane onto a visible column. +/// +/// Everything past the cap shares the last column. Pure projection, never fed +/// back into the layout: the same page re-projects for free at any width, with +/// no recomputation and no colour changing under the reader. +fn project(lane: Lane, max_lanes: usize) -> Lane { + lane.min(max_lanes.saturating_sub(1) as Lane) +} + +/// Snap a lane centre to a device pixel *before* the quad is built. +/// +/// `paint_quad` snaps the bounds it is handed, but each edge independently: an +/// unsnapped centre makes `[cx - w/2, cx + w/2]` round out to one physical +/// pixel on some rows and two on others, and a column of lines that changes +/// width as it scrolls is the most visible artefact this element can produce. +/// Same shape as `powerline_solid_edge` in the terminal renderer. +fn snap(x: f32, scale: f32) -> f32 { + if !scale.is_finite() || scale <= 0. { + return x; + } + (x * scale).round() / scale +} + +/// Centre of a visible column, relative to the gutter's left edge. +fn lane_center_x(column: Lane, scale: f32) -> f32 { + snap( + GRAPH_PAD_L + GRAPH_LANE_W * column as f32 + GRAPH_LANE_W / 2., + scale, + ) +} + +/// Total width of the gutter for a given cap. +fn gutter_width(max_lanes: usize) -> f32 { + GRAPH_PAD_L + GRAPH_LANE_W * max_lanes as f32 + GRAPH_PAD_R +} + +/// Split a conventional-commit prefix off the subject. +/// +/// Returns the type, whether it was marked breaking, and what is left. This +/// repository's subjects spend an average of 12.7 characters on the prefix, +/// which is half of what a 260px panel has to give — and the scope in the +/// middle of it is the part nobody scans for, so it goes. The type stays, in +/// front of the subject and a shade quieter than it. +/// +/// Strict on purpose. Only a lowercase ASCII type, an optional parenthesised +/// scope, an optional `!`, then `": "`. `Note: see below` and `TODO: fix` are +/// not conventional commits and keep their whole line. +fn split_conventional(subject: &str) -> (Option<(&str, bool)>, &str) { + let bytes = subject.as_bytes(); + let type_len = bytes.iter().take_while(|b| b.is_ascii_lowercase()).count(); + // Two is `ci`; past twelve it is prose that happens to start lowercase. + if !(2..=12).contains(&type_len) { + return (None, subject); + } + let mut i = type_len; + if bytes.get(i) == Some(&b'(') { + match bytes[i..].iter().position(|b| *b == b')') { + // An empty scope, `feat(): x`, is malformed; treat the line as prose. + Some(0 | 1) => return (None, subject), + Some(close) => i += close + 1, + None => return (None, subject), + } + } + let breaking = bytes.get(i) == Some(&b'!'); + if breaking { + i += 1; + } + // The space matters: `fix:it` is not a conventional commit, and without it + // a URL-bearing subject would be cut at `https:`. + if bytes.get(i) != Some(&b':') || bytes.get(i + 1) != Some(&b' ') { + return (None, subject); + } + let rest = subject[i + 2..].trim_start(); + if rest.is_empty() { + return (None, subject); + } + (Some((&subject[..type_len], breaking)), rest) +} + +/// Which colour a visible column draws with. +/// +/// A column is the overflow bundle only when the page really is wider than the +/// cap. Deciding that per page rather than per row keeps a column from changing +/// colour as the reader scrolls past the one merge that widened history. +fn column_ink(column: Lane, color: u16, max_lanes: usize, overflowing: bool, lanes: &Lanes) -> u32 { + if overflowing && column as usize + 1 == max_lanes { + return lanes.overflow; + } + lanes.ink[(color as usize).min(LANE_SLOTS - 1)] +} + +/// The segments of one row's band, already folded onto visible columns. +/// +/// Deduplicated by column, which is what makes the overflow bundle work: five +/// lanes sharing the last column produce one line, not five stacked on each +/// other at five different alphas. Later writers win, and the caller feeds +/// edges in `paint_rank` order, so the node's own line lands over anything +/// merely passing behind it. +#[derive(Default)] +struct Band { + top: [Option; GRAPH_MAX_LANES], + bottom: [Option; GRAPH_MAX_LANES], + /// `(left column, right column, colour)`, at most one per pair. + turns: Vec<(Lane, Lane, u32)>, +} + +impl Band { + fn turn(&mut self, a: Lane, b: Lane, ink: u32) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + match self.turns.iter_mut().find(|t| t.0 == lo && t.1 == hi) { + Some(existing) => existing.2 = ink, + None => self.turns.push((lo, hi, ink)), + } + } +} + +/// Fold one row's edges into the segments that will be painted. +fn band_of(row: &GraphRow, max_lanes: usize, overflowing: bool, lanes: &Lanes) -> Band { + let mut band = Band::default(); + let node = project(row.node, max_lanes); + for edge in &row.edges { + let ink = |column: Lane| column_ink(column, edge.color(), max_lanes, overflowing, lanes); + match *edge { + Edge::Pass { lane, .. } => { + let c = project(lane, max_lanes); + band.top[c as usize] = Some(ink(c)); + band.bottom[c as usize] = Some(ink(c)); + } + Edge::In { from, .. } => { + let c = project(from, max_lanes); + band.top[c as usize] = Some(ink(c)); + if c != node { + band.turn(c, node, ink(c)); + } + } + Edge::Out { to, .. } => { + let c = project(to, max_lanes); + band.bottom[c as usize] = Some(ink(c)); + if c != node { + band.turn(node, c, ink(c)); + } + } + } + } + band +} + +/// Everything the paint closure needs, snapshotted at render time so nothing +/// reaches back into the view from inside the frame. +struct GraphPaint { + page: Arc, + max_lanes: usize, + overflowing: bool, + lanes: Lanes, + /// The fill behind a hollow node, so a merge reads as a ring and not as a + /// disc with a hole punched through to whatever is under the panel. It is + /// the panel's own opaque sidebar fill rather than `theme.background`: the + /// section is flush with the panel, and `background` carries the window's + /// transparency when one is configured, which would let the lane line show + /// straight down the middle of the node. + surface: Hsla, + /// The selected row and the fill its band paints under the node, so a + /// hollow node's hole matches the selection band it sits on instead of + /// punching through to the resting surface. Hover is not covered — it + /// lives in gpui's element state, which a paint closure cannot read — so + /// a hovered ring keeps the resting hole; one step of fill under a 3px + /// hole, against a whole selected band showing the wrong colour. + selected: Option, + selected_surface: Hsla, + /// Whether a "load more" band follows the last row. + more: bool, +} + +/// Paint the whole gutter in one pass. +/// +/// Deliberately *not* wrapped in `paint_layer` per line, which is what Zed's +/// own graph does. A layer is a full-drawable render pass; a dozen of them per +/// frame is a dozen. Overlap ordering is already handled — `BoundsTree` hands +/// every overlapping primitive an increasing order — and within a row the +/// caller has sorted edges by `paint_rank` so the node's line is last. If a +/// future change makes something here look wrong in z, the fix is the sort +/// order, not a layer. +fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { + let started = std::time::Instant::now(); + let scale = window.scale_factor(); + let top = bounds.origin.y.as_f32(); + let left = bounds.origin.x.as_f32(); + let rows = &p.page.rows; + + // The canvas is as tall as the whole list, so most of it is off screen. + // The mask is the viewport; only the band it allows is worth iterating. + let mask = window.content_mask().bounds; + let first = (((mask.origin.y.as_f32() - top) / GRAPH_ROW_H).floor() as isize).max(0) as usize; + let last = ((((mask.origin.y + mask.size.height).as_f32() - top) / GRAPH_ROW_H).ceil() as isize) + .max(0) as usize; + + let cx_of = |column: Lane| left + lane_center_x(column, scale); + let vline = |x: f32, y0: f32, y1: f32| { + Bounds::from_corners( + point(px(x - GRAPH_LINE_W / 2.), px(y0)), + point(px(x + GRAPH_LINE_W / 2.), px(y1)), + ) + }; + + for (i, row) in rows + .iter() + .enumerate() + .take(last.min(rows.len())) + .skip(first) + { + let y0 = top + i as f32 * GRAPH_ROW_H; + let mid = y0 + GRAPH_ROW_H / 2.; + let band = band_of(row, p.max_lanes, p.overflowing, &p.lanes); + + for (column, ink) in band.top.iter().enumerate() { + if let Some(ink) = ink { + window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), gpui::rgb(*ink))); + } + } + for (column, ink) in band.bottom.iter().enumerate() { + if let Some(ink) = ink { + window.paint_quad(fill( + vline(cx_of(column as Lane), mid, y0 + GRAPH_ROW_H), + gpui::rgb(*ink), + )); + } + } + // Cross-lane turns are right angles, which is what tig, lazygit and + // `git log --graph` all draw and what reads unambiguously at a 12px + // pitch. Curves would mean paths, and paths mean a render pass each. + // Swapping them in later touches only this loop: a curve consumes the + // same `(lo, hi, mid)` a right angle does. + // + // The horizontal runs half a line width past both centres, which is + // exactly what fills the two outside corners the vertical stubs leave + // open. Without it a turn shows a notch at every elbow. + for (lo, hi, ink) in &band.turns { + window.paint_quad(fill( + Bounds::from_corners( + point( + px(cx_of(*lo) - GRAPH_LINE_W / 2.), + px(mid - GRAPH_LINE_W / 2.), + ), + point( + px(cx_of(*hi) + GRAPH_LINE_W / 2.), + px(mid + GRAPH_LINE_W / 2.), + ), + ), + gpui::rgb(*ink), + )); + } + + let node = project(row.node, p.max_lanes); + let ink = gpui::rgb(column_ink( + node, + row.color, + p.max_lanes, + p.overflowing, + &p.lanes, + )); + let cx = cx_of(node); + let dot = |r: f32| { + Bounds::from_corners( + point(px(cx - r), px(mid - r)), + point(px(cx + r), px(mid + r)), + ) + }; + // A rounded quad rather than a path: the quad shader's rounding is an + // exact SDF with analytic anti-aliasing, where `PathBuilder` fills every + // vertex's `st` with `(0, 1)` and so falls back on 4x MSAA alone. The + // hollow ones are one bordered quad rather than two concentric fills, + // because the border is part of that same SDF — stacking would blend the + // inner edge over the outer one's already-blended edge, and a 3px hole + // is where that shows. + let hole = if p.selected == Some(i) { + p.selected_surface + } else { + p.surface + }; + if row.parents > 1 { + // A merge is a ring. It is the one row shape a reader scans for, + // and an outline reads at 8px where a second fill colour does not. + let r = GRAPH_DOT_R + 1.; + window.paint_quad(quad( + dot(r), + Corners::all(px(r)), + hole, + Edges::all(px(GRAPH_LINE_W)), + ink, + BorderStyle::Solid, + )); + } else if row.parents == 0 { + // A root has nothing below it; hollow says "the line stops here" + // without needing a second glyph. + window.paint_quad(quad( + dot(GRAPH_DOT_R), + Corners::all(px(GRAPH_DOT_R)), + hole, + Edges::all(px(GRAPH_LINE_W)), + ink, + BorderStyle::Solid, + )); + } else { + window.paint_quad( + fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))), + ); + } + } + + // Past the last row the lanes that are still open get a stub. Without it a + // page boundary reads as a row of root commits — every line simply ending. + // Under a "load more" row the stubs run the full band instead, so the graph + // reads as continuing through the control rather than being cut by it. + if last > rows.len() && !p.page.open_lanes.is_empty() { + let y0 = top + rows.len() as f32 * GRAPH_ROW_H; + for lane in &p.page.open_lanes { + let column = project(*lane, p.max_lanes); + let ink = column_ink(column, *lane, p.max_lanes, p.overflowing, &p.lanes); + let x = cx_of(column); + if p.more { + window.paint_quad(fill( + vline(x, y0, y0 + GRAPH_ROW_H), + Hsla::from(gpui::rgb(ink)).opacity(0.3), + )); + } else { + // Three steps rather than a gradient: a gradient would be a + // second `Background` kind for four pixels of ink. + const STEP: f32 = 3.; + for (step, alpha) in [0.5f32, 0.3, 0.15].into_iter().enumerate() { + let a = y0 + step as f32 * STEP; + window.paint_quad(fill( + vline(x, a, a + STEP), + Hsla::from(gpui::rgb(ink)).opacity(alpha), + )); + } + } + } + } + + if crate::ui::perf::enabled() { + crate::ui::perf::record("scm.graph.paint", started.elapsed()); + } +} + +impl Tty7App { + /// The history section, when it is expanded and has something to draw. + /// + /// Sits below the file list as its own scroll region rather than at the end + /// of one: the graph pages, and sharing a scroller would mean scrolling back + /// past hundreds of commits to reach the message box. + pub(crate) fn render_graph_section( + &mut self, + repo: &RepoKey, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.scm.graph.expanded { + // Folded, the section is one line — but it keeps the rule above it, + // or it reads as the last row of the file list rather than as a + // section of its own. + return Some( + div() + .flex_none() + .border_t_1() + .border_color(cx.theme().border) + .child(self.graph_header(repo, None, cx)) + .into_any_element(), + ); + } + self.scm_load_graph(repo, cx); + + if self.scm.graph.height.get() <= 0. { + self.scm.graph.height.set(GRAPH_H_DEFAULT); + } + let ceiling = (window.viewport_size().height.as_f32() * GRAPH_H_MAX_RATIO).max(GRAPH_H_MIN); + let height = self.scm.graph.height.get().clamp(GRAPH_H_MIN, ceiling); + + let page = self.scm.graph.page.clone(); + let header = self.graph_header(repo, page.as_deref(), cx); + let search = self.graph_search(cx); + let naming = self.graph_naming_row(repo, cx); + let query = self.graph_query(cx); + let body = match page { + None => self.panel_empty(t(L10nKey::PanelLoading), None, cx), + Some(page) if page.commits.is_empty() => { + self.panel_empty(t(L10nKey::ScmGraphEmpty), None, cx) + } + Some(page) => self.graph_body(repo, &page, query.as_deref(), height, cx), + }; + let (backing, handle) = self.graph_resize(ceiling, cx); + + Some( + v_flex() + .relative() + .flex_none() + .h(px(height)) + .border_t_1() + .border_color(cx.theme().border) + .child(backing) + .child(header) + .children(search) + .children(naming) + .child(body) + .child(handle) + .into_any_element(), + ) + } + + /// Which refs the current settings walk from. + fn graph_scope(&self) -> GraphScope { + self.scm.graph.scope.clone() + } + + /// Ask for a page, at most once per (repository, scope, size). + /// + /// Paging grows `requested` and re-runs the query rather than paging with + /// `--skip`. The layout is deterministic, so a longer run reproduces the + /// same prefix row for row — nothing already on screen moves — where + /// `--skip` is O(skip) to walk and slides under you the moment a ref moves. + fn scm_load_graph(&mut self, repo: &RepoKey, cx: &mut Context) { + // A page that belongs to another repository must neither be drawn nor + // grown from: this runs before the frame reads `page`, so the switch + // shows the loading state, never seconds of the previous repository's + // history — where a row click would build an op for the new repo with + // the old repo's rev. The page size resets with it; how deep someone + // read one history says nothing about the next. + if self + .scm + .graph + .page_key + .as_ref() + .is_some_and(|(r, _, _)| r != repo) + { + self.scm.graph.page = None; + self.scm.graph.page_key = None; + self.scm.graph.requested = 0; + } + // `try_global`, never `default_global`: this runs from `render`, and + // taking the global mutably there queues a global-observer effect on + // every frame, which is a panel that asks for a frame from inside one. + let epoch = cx + .try_global::() + .map_or(0, |data| data.epoch(repo.host, &repo.root)); + let scope = self.graph_scope(); + // Clamped like `load_page` clamps it: `requested` above the cap with a + // page that answers *at* the cap would read as never-fresh, and the + // panel would refetch the same full page from every frame's render. + let want = self + .scm + .graph + .requested + .clamp(GRAPH_PAGE, MAX_GRAPH_COMMITS); + let key = (repo.clone(), epoch, scope.clone()); + let fresh = self.scm.graph.page_key.as_ref() == Some(&key) + && self + .scm + .graph + .page + .as_ref() + .is_some_and(|p| p.requested >= want); + // A load that failed is not retried until its key changes — an epoch + // bump, a new scope, a new repository. Retrying from render would + // spawn git processes in a tight loop for as long as the cause holds. + if fresh || self.scm.graph.loading || self.scm.graph.failed_key.as_ref() == Some(&key) { + return; + } + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, repo.host) else { + return; + }; + self.scm.graph.loading = true; + let root = repo.root.clone(); + let query = scope.clone(); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| tty7_core::core::git::log::load_page(h, &root, &query, want), + move |this, page, cx| { + this.scm.graph.loading = false; + match page { + Some(page) => { + this.scm.graph.failed_key = None; + this.scm.graph.page = Some(Arc::new(page)); + this.scm.graph.page_key = Some(key); + } + None => this.scm.graph.failed_key = Some(key), + } + cx.notify(); + }, + ); + } + + /// Which commit indices the list shows for this page and query, resolved + /// through the cache on `GraphState` — see its doc for why it exists. + fn graph_visible_rows( + &mut self, + page: &Arc, + query: Option<&str>, + ) -> Arc> { + let key = Arc::as_ptr(page) as usize; + if let Some((held_query, held_page, rows)) = &self.scm.graph.filter_cache { + if *held_page == key && held_query.as_deref() == query { + return rows.clone(); + } + } + let rows: Arc> = Arc::new(match query { + None => (0..page.commits.len()).collect(), + Some(q) => (0..page.commits.len()) + .filter(|i| matches_query(&page.commits[*i], q)) + .collect(), + }); + self.scm.graph.filter_cache = Some((query.map(str::to_string), key, rows.clone())); + rows + } + + /// The filter box's text, if it has any. + fn graph_query(&self, cx: &Context) -> Option { + let input = self.scm.graph.search.as_ref()?; + let text = input.read(cx).value().trim().to_lowercase(); + (!text.is_empty()).then_some(text) + } + + /// The filter field, drawn only while it is open. + /// + /// At rest the section is a title and a list; a permanently parked search + /// box was a row of chrome above every reading of history, for a thing that + /// gets used once a session. It lives behind the header's tile now. + fn graph_search(&self, cx: &mut Context) -> Option { + let input = self.scm.graph.search.clone()?; + Some(self.panel_search(&input, cx)) + } + + /// Show the filter field, or take it away again. + /// + /// The `InputState` *is* the open flag: there is no second boolean to keep + /// in step with it, and closing the field drops the entity, which is what + /// answers the question a hidden filter always raises. A query cannot go on + /// quietly cutting rows out of the list from behind a closed box, because + /// there is nothing left holding the text — `graph_query` reads the input + /// or reads nothing. The cost is that reopening starts empty, which is the + /// right trade for a filter this shallow: retyping four characters is + /// cheaper than wondering why history is missing. + /// + /// Created here rather than on first render, the way `commit_input` still + /// is: an `InputState` needs a real window, and the click that asks for one + /// has one in hand. + fn graph_toggle_search(&mut self, window: &mut Window, cx: &mut Context) { + if self.scm.graph.search.take().is_some() { + self.scm.graph.search_sub = None; + cx.notify(); + return; + } + let input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder(t(L10nKey::ScmGraphFilterPlaceholder)) + }); + // Without this the box would take text the list never sees: an + // `InputState` is its own entity, and its changes are its own events. + self.scm.graph.search_sub = + Some( + cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| { + if matches!(ev, gpui_component::input::InputEvent::Change) { + cx.notify(); + } + }), + ); + let handle = input.read(cx).focus_handle(cx); + self.scm.graph.search = Some(input); + // A field that appears without the caret in it is a field you have to + // click twice. + window.focus(&handle, cx); + cx.notify(); + } +} + +impl Tty7App { + /// The scrolling list: rows underneath, one canvas over the gutter. + /// + /// `height` is the section's height — the ceiling on how much of the list + /// can be on screen, and so on how many rows become elements. + fn graph_body( + &mut self, + repo: &RepoKey, + page: &Arc, + query: Option<&str>, + height: f32, + cx: &mut Context, + ) -> AnyElement { + let panel_w = cx.global::().right_panel_width; + let cap = max_lanes(panel_w); + let gutter = gutter_width(cap); + let now = crate::ui::home::now_secs() as i64; + + // A filtered view drops rows out of the middle of history, and lanes + // drawn across a subset would connect commits that are not adjacent. + // So the filter hides the gutter entirely and the list becomes a flat + // search result — which is what it actually is. + let filtering = query.is_some(); + let rows = self.graph_visible_rows(page, query); + // No row at the cap either: `load_page` clamps there, so "load more" + // past it could only refetch what is already on screen. + let more = query.is_none() && !page.complete && page.commits.len() < MAX_GRAPH_COMMITS; + let bands = rows.len() + usize::from(more); + + // Only the rows that can be on screen become elements — the row count + // is bounded by the cap at 5000, and a taffy pass over 5000 flex + // children per frame is most of a frame. The stack below keeps its + // full fixed height so the scroll range is unchanged; a top padding + // stands in for everything scrolled past. The canvas needs no such + // treatment: its paint is already clipped to the content mask. + let scrolled = (-self.scm.graph.scroll.offset().y.as_f32()).max(0.); + let first = ((scrolled / GRAPH_ROW_H) as usize) + .saturating_sub(GRAPH_WINDOW_MARGIN) + .min(rows.len()); + let visible = (height / GRAPH_ROW_H).ceil() as usize + GRAPH_WINDOW_MARGIN * 2; + let last = first.saturating_add(visible).min(rows.len()); + + // With the gutter gone the text takes the panel's own inset, so a + // search result does not sit in a column of empty space. + let indent = if filtering { CONTENT_INSET } else { gutter }; + let list = v_flex().pt(px(first as f32 * GRAPH_ROW_H)).children( + rows[first..last] + .iter() + .map(|i| self.graph_row(repo, page, *i, indent, now, cx)), + ); + let mut stack = div() + .relative() + .w_full() + .h(px(bands as f32 * GRAPH_ROW_H)) + .child(list) + .children(more.then(|| self.graph_load_more(gutter, cx))); + + if !filtering { + let sf = cx.global::().sidebar; + let paint = GraphPaint { + page: page.clone(), + max_lanes: cap, + overflowing: page.max_lanes as usize > cap, + lanes: cx.global::().0, + // The hole in a hollow node has to be the exact fill behind it, + // or the lane line running underneath shows through. The + // section is flush on the panel, so that fill is the sidebar's. + surface: gpui::rgb(sf.base).into(), + selected: self + .scm + .graph + .selected + .as_deref() + .and_then(|oid| page.commits.iter().position(|c| c.oid == oid)), + selected_surface: gpui::rgb(sf.selected).into(), + more, + }; + stack = stack.child( + canvas( + |_, _, _| (), + move |bounds, _, window, _| paint_graph(&paint, bounds, window), + ) + .absolute() + .top_0() + .left_0() + .w(px(gutter)) + .h_full(), + ); + } + + let scroller = div() + .id("scm-graph") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scm.graph.scroll) + .child(stack); + crate::ui::scrollbar::with_vertical_scrollbar( + "scm-graph-scrollbar", + scroller, + &self.scm.graph.scroll, + ) + } + + /// One commit. + /// + /// Column order is fixed and every optional part has a hard cap, so the + /// worst case cannot squeeze the message to nothing: type prefix, message, + /// ref chip, age. The age goes when a ref chip is present — a chip says + /// where a branch is, which is worth more here than three characters of + /// "3d", and the full timestamp is in the tooltip either way. + fn graph_row( + &self, + repo: &RepoKey, + page: &Arc, + i: usize, + gutter: f32, + now: i64, + cx: &mut Context, + ) -> AnyElement { + let commit = &page.commits[i]; + let mono = cx.theme().mono_font_family.clone(); + // The sidebar's surface, because that is the fill this row sits on. + let sf = cx.global::().sidebar; + let selected = self.scm.graph.selected.as_deref() == Some(commit.oid.as_str()); + let (prefix, subject) = split_conventional(&commit.summary); + let deco = commit.refs.first(); + let extra = commit.refs.len().saturating_sub(1); + let oid = commit.oid.clone(); + + h_flex() + .id(SharedString::from(format!("scm-graph-row-{i}"))) + .items_center() + .gap(px(4.)) + .h(px(GRAPH_ROW_H)) + .pl(px(gutter)) + .pr(px(CONTENT_INSET)) + .cursor_pointer() + // The panel's own neutral selection and hover fills, the same two + // the file list above uses. A tinted band would make this one list + // in the sidebar announce itself differently from every other. + .when(selected, |d| d.bg(gpui::rgb(sf.selected))) + .when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover)))) + // Prefix and subject are one run of text, so they sit closer than + // the row's own gap: two pixels between `feat` and the words it + // introduces is about a word space at this size, and reads as one + // rather than as the space between two elements. The outer gap + // still separates them from the ref chip and the age, which *are* + // other elements. + .child( + h_flex() + .flex_1() + .min_w(px(0.)) + .gap(px(2.)) + .children( + prefix.map(|(kind, breaking)| self.graph_type_prefix(kind, breaking, cx)), + ) + .child( + // The only full-strength ink on the row, and the widest + // thing on it. Everything else — the type, the age, the + // refs, the lanes — is an annotation on this. + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size(px(12.)) + .text_color(cx.theme().foreground) + .child(SharedString::from(subject.to_string())), + ), + ) + .children(deco.map(|r| self.graph_ref_chip(r, extra, &mono, cx))) + // The age is a mono token, so its column is measured in characters: + // the widest it ever prints is four (`12mo`), and four of the mono + // face's advances at 10.5px is 25.2, rounded up to 26. + .when(deco.is_none(), |d| { + d.child( + div() + .flex_none() + .min_w(px(26.)) + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time( + now, + commit.author.at.unix, + ))), + ) + }) + .tooltip({ + let text = commit_tooltip(commit, now); + move |window, cx| { + gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) + } + }) + .on_click(cx.listener({ + let repo = repo.clone(); + // The row already holds everything the detail view renders, so + // it hands its own commit over and no `git show` is run. The + // listener carries the page `Arc` and an index, not a clone of + // the commit: with up to 5000 rows a frame, one deep `Commit` + // clone per row (an 8KB body, refs) was most of the frame. + let page = page.clone(); + move |this, _, _, cx| { + let seed = page.commits[i].clone(); + this.graph_open_commit(repo.clone(), seed.oid.clone(), Some(seed), cx) + } + })) + .context_menu({ + let app = cx.entity().downgrade(); + let repo = repo.clone(); + let oid = oid.clone(); + move |menu, _window, cx| { + let danger = cx.theme().danger; + Tty7App::graph_row_context_menu(menu, &repo, &oid, danger, &app) + } + }) + .into_any_element() + } +} + +impl Tty7App { + /// Select a row and hand its commit to the detail view. + /// + /// `seed` is the commit the row was drawn from. Passing it means the + /// detail view opens with its message, author and refs already in hand and + /// only reads the file list — a row click costs one git command, not two. + fn graph_open_commit( + &mut self, + repo: RepoKey, + oid: String, + seed: Option, + cx: &mut Context, + ) { + self.scm.graph.selected = Some(oid.clone()); + self.open_commit_detail(repo, oid, seed, cx); + } +} + +/// One "load more" click's worth of growth, stopped at what `load_page` will +/// actually answer. +/// +/// Growing past [`MAX_GRAPH_COMMITS`] would ask for a page the loader clamps: +/// the answer would never satisfy `requested >= want`, and the panel would +/// refetch the same full page from every frame's render, forever. +fn next_page_request(requested: usize) -> usize { + requested + .max(GRAPH_PAGE) + .saturating_add(GRAPH_PAGE) + .min(MAX_GRAPH_COMMITS) +} + +/// Whether a commit answers the filter box. +/// +/// Subject, author and sha, all case-folded. Not the body: a search that +/// matches on text the row cannot show is a search whose results look wrong. +fn matches_query(commit: &Commit, query: &str) -> bool { + commit.summary.to_lowercase().contains(query) + || commit.author.name.to_lowercase().contains(query) + || commit.oid.starts_with(query) +} + +fn commit_tooltip(commit: &Commit, now: i64) -> SharedString { + SharedString::from(format!( + "{}\n{} · {} · {}", + commit.summary, + commit.short(), + commit.author.name, + relative_time(now, commit.author.at.unix) + )) +} + +/// What ink a conventional-commit prefix is set in. +/// +/// One rule rather than a palette, and the type is not what decides it. The +/// lane gutter immediately to the left is already a column of colour, so a +/// green `feat` beside a green lane dot adds a second colour column that says +/// nothing the dot has not: the reader's eye is pulled twice and told the same +/// thing once. Muting the whole vocabulary — `feat`, `fix`, `perf`, `docs` and +/// whatever a repository invents — leaves the row with two levels of text +/// emphasis, which is all a sidebar has ever needed. +/// +/// The one exception is a breaking change. A `!` is the single thing in a +/// subject line worth shouting about, whatever the type in front of it says. +fn type_tone(breaking: bool, cx: &gpui::App) -> Hsla { + let theme = cx.theme(); + match breaking { + true => theme.danger, + false => theme.muted_foreground, + } +} + +impl Tty7App { + /// The prefix, set inline as the first word of the subject. + /// + /// Deliberately not a chip. A filled pill on every row is a colour column + /// running down the panel right beside the one the lanes already draw, and + /// because the types are different lengths — `fix`, `chore`, `refactor` — + /// the pills gave every subject a different left edge, so nothing in the + /// list lined up vertically. With no fill and no padding of its own, the + /// two halves read as one sentence and the column starts where the prefix + /// does. + /// + /// Level with the subject at 12px and told apart by tone alone. The two + /// halves are one sentence, and a size change mid-sentence is a seam: the + /// muting already says which half a reader is meant to skip, and saying it + /// twice buys nothing but a ragged line. + /// + /// Not mono either, for the same reason it is not a chip: the subject + /// beside it is not, and a family change mid-run is a seam where the point + /// is continuity. + fn graph_type_prefix(&self, kind: &str, breaking: bool, cx: &mut Context) -> AnyElement { + div() + // Never the part that truncates. It is a handful of characters, and + // clipping them to buy the subject the same handful is not a trade + // — a half-eaten `refac…` costs a reader more than it gives back. + .flex_none() + .text_size(px(12.)) + .text_color(type_tone(breaking, cx)) + .child(SharedString::from(match breaking { + true => format!("{kind}!"), + false => kind.to_string(), + })) + .into_any_element() + } + + /// The highest-priority ref on a commit, plus a count of the rest. + /// + /// `load_page` already sorted them HEAD → local → tag → remote, so the + /// first one is the one worth the width. + fn graph_ref_chip( + &self, + deco: &RefDeco, + extra: usize, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let (bg, fg, weight) = match deco.kind { + // Where you are is the one thing on this row worth a heavier + // weight; everything else is context. The fill is grey, not brand: + // `theme.accent` is a neutral surface tint here, and the only + // saturated colour the section spends is a lane's. + RefKind::Head => ( + theme.accent.opacity(0.28), + theme.foreground, + gpui::FontWeight::SEMIBOLD, + ), + // Tags are yellow because tags are yellow — in git's own output, + // in every other client, and in the reader's memory. + RefKind::Tag => ( + theme.warning.opacity(0.16), + theme.warning, + gpui::FontWeight::NORMAL, + ), + // Everything else — a local branch you are not on, a remote + // tracking ref — is context, and context does not get a box. A + // filled grey pill here was a third block of surface on a row that + // already carries a lane gutter and a subject; unfilled, it reads + // as a label sitting after the message, which is what it is. + _ => ( + gpui::transparent_black(), + theme.muted_foreground, + gpui::FontWeight::NORMAL, + ), + }; + let label = match extra { + 0 => elide_middle(&deco.short, GRAPH_REF_CHARS).into_owned(), + n => format!("{} +{n}", elide_middle(&deco.short, GRAPH_REF_CHARS)), + }; + // A hard cap, because the chip is the one part of the row whose width + // comes from a branch name someone else chose. `GRAPH_REF_CHARS` elides + // the middle first; this is the backstop for the widths that survive + // it — fourteen characters of `info_chip`'s 10.5px mono plus its + // padding, which is about 72. + div() + .flex_none() + .max_w(px(72.)) + .truncate() + .font_weight(weight) + .child(info_chip(&label, bg, fg, mono)) + .into_any_element() + } + + /// The band under the last row that asks for the next page. + /// + /// A row rather than a scroll trigger. A remote `git log` is an RPC across + /// a host boundary, and scroll-to-load turns one flick of a trackpad into a + /// burst of concurrent ones. + fn graph_load_more(&self, gutter: f32, cx: &mut Context) -> AnyElement { + let loading = self.scm.graph.loading; + h_flex() + .id("scm-graph-more") + .items_center() + .h(px(GRAPH_ROW_H)) + .pl(px(gutter)) + .pr(px(CONTENT_INSET)) + .cursor_pointer() + // A step under the subjects above it: this is a control the list + // offers, not a commit, and it should not read as one more row of + // history. + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .hover(|s| s.text_color(cx.theme().foreground)) + .child(SharedString::from(match loading { + true => t(L10nKey::PanelLoading), + false => t(L10nKey::ScmGraphLoadMore), + })) + .on_click(cx.listener(|this, _, _, cx| { + this.scm.graph.requested = next_page_request(this.scm.graph.requested); + cx.notify(); + })) + .into_any_element() + } +} + +impl Tty7App { + /// The section's own title row: fold, title and count on the left, the + /// filter tile and the scope picker on the right. + /// + /// The arrangement is the part worth keeping — a count that reads as part + /// of the title, and the two controls collected at the trailing edge rather + /// than strung out beside the label. The dress is the panel's: muted ink + /// and no control tinted, because nothing here is more important than the + /// file list above it. + /// + /// The title is 11px MEDIUM and the count 10.5px beside it, both muted: + /// this is a band label, not a heading a reader is meant to stop at, and + /// the rows below it are what the section is for. The count is set in the + /// UI font rather than mono — it sits inside the title's own phrase, and a + /// family change there would read as a token rather than as part of it. + /// + /// There is no lane-gutter fold. The lanes are the graph. + fn graph_header( + &self, + repo: &RepoKey, + page: Option<&CommitPage>, + cx: &mut Context, + ) -> AnyElement { + let expanded = self.scm.graph.expanded; + let muted = cx.theme().muted_foreground; + let filtering = self.scm.graph.search.is_some(); + let count = page.map(|p| match p.complete { + true => p.commits.len().to_string(), + false => format!("{}+", p.commits.len()), + }); + + h_flex() + .flex_none() + .items_center() + .gap(px(4.)) + .h(px(GRAPH_HEADER_H)) + .pl(px(CONTENT_INSET)) + .pr(px(crate::ui::app::tile_trailing_inset_sm())) + .child( + h_flex() + .id("scm-graph-fold") + .items_center() + .gap(px(4.)) + .flex_1() + .min_w(px(0.)) + .cursor_pointer() + .child( + // A hair under the title it opens: the chevron is a + // mark, not a word, and at the label's own size it + // starts competing with it for the corner. + Icon::new(match expanded { + true => IconName::ChevronDown, + false => IconName::ChevronRight, + }) + .size(px(10.)) + .text_color(muted), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(muted) + .child(SharedString::from(t(L10nKey::ScmGraphTitle))), + ) + // The count reads as part of the title, so it sits with it + // rather than at the far end of the row — half a step + // smaller and at normal weight, which is the whole + // difference between the two words in this corner. + .children(count.map(|c| { + div() + .text_size(px(10.5)) + .text_color(muted) + .child(SharedString::from(c)) + })) + .on_click(cx.listener(|this, _, _, cx| this.scm_toggle_graph(cx))), + ) + .when(expanded, |row| { + row.child( + // Lit while the field is open, which is the only signal + // that history is being filtered once the box is gone — + // and it cannot go stale, because closing the box is what + // throws the query away. + crate::ui::tab_strip::chrome_tile_sized( + Button::new("scm-graph-filter").icon(Icon::new(IconName::Search)), + GRAPH_TILE, + GRAPH_TILE_GLYPH, + filtering, + cx, + ) + .rounded(px(4.)) + .tooltip(t(L10nKey::ScmGraphFilterPlaceholder)) + .on_click( + cx.listener(|this, _, window, cx| this.graph_toggle_search(window, cx)), + ), + ) + .child( + // The same square as the tile beside it, or the two + // controls in this corner sit on different baselines. + Button::new("scm-graph-scope") + .ghost() + .xsmall() + .h(px(GRAPH_TILE)) + .rounded(px(4.)) + .dropdown_caret(true) + .label(scope_label(&self.scm.graph.scope)) + .text_color(muted) + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + self.graph_scope_menu(repo, cx), + ), + ) + }) + .into_any_element() + } + + fn graph_scope_menu( + &self, + repo: &RepoKey, + cx: &mut Context, + ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static + use<> + { + let app = cx.entity().downgrade(); + let branches = self + .scm + .branches + .get(repo) + .map(|(_, names)| names.clone()) + .unwrap_or_default(); + let scope = self.scm.graph.scope.clone(); + + move |menu, _window, _cx| { + let mut menu = menu.min_w(px(180.)); + let pick = |app: &gpui::WeakEntity, next: GraphScope| { + let app = app.clone(); + move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut gpui::App| { + let _ = app.update(cx, |this, cx| this.graph_set_scope(next.clone(), cx)); + } + }; + for (label, next) in [ + ( + t(L10nKey::ScmGraphCurrentBranch), + GraphScope::HeadAndUpstream, + ), + (t(L10nKey::ScmGraphAllBranches), GraphScope::All), + ] { + menu = menu.item( + PopupMenuItem::new(label) + .checked(scope == next) + .on_click(pick(&app, next)), + ); + } + if !branches.is_empty() { + menu = menu.separator(); + } + for name in &branches { + let next = GraphScope::Refs(vec![format!("refs/heads/{name}")]); + menu = menu.item( + PopupMenuItem::new(name.clone()) + .checked(scope == next) + .on_click(pick(&app, next)), + ); + } + menu + } + } + + /// Point the graph at a different set of refs, and start it over. + /// + /// The page count resets with the scope: keeping a grown `requested` would + /// make switching to a short branch pull its whole history in one go. + fn graph_set_scope(&mut self, scope: GraphScope, cx: &mut Context) { + if self.scm.graph.scope == scope { + return; + } + self.scm.graph.scope = scope; + self.scm.graph.requested = GRAPH_PAGE; + self.scm.graph.page = None; + self.scm.graph.page_key = None; + cx.notify(); + } +} + +fn scope_label(scope: &GraphScope) -> String { + match scope { + GraphScope::All => t(L10nKey::ScmGraphAllBranches).to_string(), + GraphScope::Refs(refs) => refs + .first() + .map(|r| r.rsplit('/').next().unwrap_or(r).to_string()) + .unwrap_or_else(|| t(L10nKey::ScmGraphAllBranches).to_string()), + _ => t(L10nKey::ScmGraphCurrentBranch).to_string(), + } +} + +impl Tty7App { + /// The row's verbs. + /// + /// Every one of them goes through `scm_op`, which is where the confirmation + /// for anything that can lose work already lives — a second gate here would + /// be a second thing to keep in step with `GitOp::destructive`. + fn graph_row_context_menu( + menu: PopupMenu, + repo: &RepoKey, + oid: &str, + danger: Hsla, + app: &gpui::WeakEntity, + ) -> PopupMenu { + let op = |app: &gpui::WeakEntity, repo: &RepoKey, build: fn(String) -> GitOp| { + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut gpui::App| { + let _ = app.update(cx, |this, cx| { + this.scm_op(repo.clone(), build(rev.clone()), window, cx) + }); + } + }; + + let mut menu = menu + .min_w(px(200.)) + .item( + PopupMenuItem::new(t(L10nKey::ScmCheckoutCommit)) + .on_click(op(app, repo, |rev| GitOp::CheckoutDetached { rev })), + ) + .item( + PopupMenuItem::new(t(L10nKey::ScmCreateBranchHere)).on_click({ + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.graph_begin_branch_at(repo.clone(), rev.clone(), window, cx) + }); + } + }), + ) + .separator() + .item( + PopupMenuItem::new(t(L10nKey::ScmCherryPick)).on_click(op(app, repo, |rev| { + GitOp::CherryPick { + rev, + // A merge cherry-picked without `-m` is an error, and + // the first parent is the only sane default. + mainline: true, + no_commit: false, + } + })), + ) + .item( + PopupMenuItem::new(t(L10nKey::ScmRevertCommit)).on_click(op(app, repo, |rev| { + GitOp::Revert { + rev, + mainline: true, + } + })), + ) + .separator(); + + for (label, mode) in [ + (t(L10nKey::ScmResetSoft), ResetMode::Soft), + (t(L10nKey::ScmResetMixed), ResetMode::Mixed), + (t(L10nKey::ScmResetHard), ResetMode::Hard), + ] { + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + // `--hard` is the one entry here that discards work outright, so + // it wears the danger colour the same way the tree's Delete does. + // The confirmation still comes from `scm_op`; this is the warning + // before the warning. + let base = match mode { + ResetMode::Hard => PopupMenuItem::element(move |_window, _cx| { + div().text_color(danger).child(label) + }), + _ => PopupMenuItem::new(label), + }; + let item = base.on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm_op( + repo.clone(), + GitOp::Reset { + rev: rev.clone(), + mode, + }, + window, + cx, + ) + }); + }); + menu = menu.item(item); + } + + menu.separator() + .item(PopupMenuItem::new(t(L10nKey::ScmCopyCommitSha)).on_click({ + let rev = oid.to_string(); + move |_, _, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string(rev.clone())) + })) + } + + /// `right_panel_resize` rotated onto the other axis: a canvas that remembers + /// the container's bounds, an `Rc` pair for the live value and the + /// drag flag, and a one-pixel hairline that only shows on hover or while + /// held. + fn graph_resize(&self, ceiling: f32, cx: &mut Context) -> (AnyElement, AnyElement) { + let container: Rc>>> = Rc::new(StdCell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let height = self.scm.graph.height.clone(); + let dragging = self.scm.graph.dragging.clone(); + move |_bounds, _state, window: &mut Window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let height = height.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window: &mut Window, _cx| { + if !dragging.get() { + return; + } + let Some(b) = container.get() else { + return; + }; + // Measured from the bottom, because that edge is + // pinned and the top is the one being dragged. + let raw = (b.origin.y + b.size.height - ev.position.y).as_f32(); + height.set(raw.clamp(GRAPH_H_MIN, ceiling)); + window.refresh(); + } + }); + window.on_mouse_event({ + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window: &mut Window, _cx| { + if !dragging.get() { + return; + } + dragging.set(false); + window.refresh(); + } + }); + } + }, + ) + .absolute() + .size_full() + .into_any_element(); + + let active = self.scm.graph.dragging.get(); + let handle = div() + .group("scm-graph-resize") + .occlude() + .absolute() + .left_0() + .top(px(-(GRAPH_HANDLE_H / 2.))) + .w_full() + .h(px(GRAPH_HANDLE_H)) + .flex() + .items_center() + .cursor_row_resize() + .child( + div() + .w_full() + .h(px(1.)) + .when(active, |d| d.bg(cx.theme().drag_border)) + .group_hover("scm-graph-resize", |s| s.bg(cx.theme().drag_border)), + ) + .on_mouse_down(MouseButton::Left, { + let dragging = self.scm.graph.dragging.clone(); + move |_ev, window: &mut Window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + .into_any_element(); + + (backing, handle) + } +} + +impl Tty7App { + /// Open the inline "name a branch here" input for one commit. + /// + /// Its own input rather than the panel's naming row: that row always + /// branches from HEAD, and a branch created at the wrong commit is a + /// silent mistake rather than a visible one. + fn graph_begin_branch_at( + &mut self, + repo: RepoKey, + rev: String, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder(t(L10nKey::ScmCreateBranchHere)) + }); + let handle = input.read(cx).focus_handle(cx); + self.scm.graph.naming = Some((input, rev)); + self.scm.repo_override = Some(repo); + self.scm.override_tab = Some(self.active); + window.focus(&handle, cx); + cx.notify(); + } + + /// The inline "name a branch here" field. + /// + /// `xsmall`, like every other inline field in this panel: a 20px box in a + /// 30px row, which is the input plus 5px of air each side. A taller one + /// here would push the list it interrupts down by more than the field is + /// worth. + fn graph_naming_row(&mut self, repo: &RepoKey, cx: &mut Context) -> Option { + let (input, rev) = self.scm.graph.naming.clone()?; + let repo = repo.clone(); + Some( + h_flex() + .id("scm-graph-naming") + .flex_none() + .items_center() + .h(px(30.)) + .px(px(CONTENT_INSET)) + .child( + div() + .flex_1() + .min_w(px(0.)) + // `appearance(false)` like every other field in the + // app: left on, gpui-component draws its own border and + // fill, which is the one chrome nothing else in this + // panel wears. + .child( + gpui_component::input::Input::new(&input) + .appearance(false) + .xsmall(), + ), + ) + .on_key_down( + cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| { + match ev.keystroke.key.as_str() { + "escape" => { + this.scm.graph.naming = None; + cx.notify(); + } + "enter" => { + let Some((input, _)) = this.scm.graph.naming.take() else { + return; + }; + let name = input.read(cx).value().trim().to_string(); + cx.notify(); + if name.is_empty() { + return; + } + this.scm_op( + repo.clone(), + GitOp::CreateBranch { + name, + start: Some(rev.clone()), + // Naming a branch at an old commit is + // usually marking a place, not moving to + // it — and moving would take the working + // tree with it. + checkout: false, + }, + window, + cx, + ); + } + _ => {} + } + }), + ) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::app::test_window::harness; + use crate::ui::host_ops::HostId; + use gpui::TestAppContext; + use smallvec::smallvec; + use std::path::PathBuf; + + fn repo() -> RepoKey { + RepoKey { + host: HostId::LOCAL, + root: PathBuf::from("/tmp/tty7-graph-test"), + } + } + + fn lanes() -> Lanes { + Lanes { + ink: [0x111111, 0x222222, 0x333333, 0x444444, 0x555555, 0x666666], + overflow: 0x999999, + } + } + + #[test] + fn lanes_inside_the_cap_keep_their_own_column() { + for cap in GRAPH_MIN_LANES..=GRAPH_MAX_LANES { + let columns: Vec = (0..cap as Lane).map(|l| project(l, cap)).collect(); + let mut sorted = columns.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + columns.len(), + sorted.len(), + "cap {cap} collapsed a real lane" + ); + assert_eq!(columns, (0..cap as Lane).collect::>()); + } + } + + #[test] + fn everything_past_the_cap_lands_in_the_overflow_column() { + let cap = 4; + for lane in 4u16..=tty7_core::core::git::log::MAX_LANES { + assert_eq!(project(lane, cap), 3, "lane {lane} escaped the last column"); + } + // `max_lanes` never goes below `GRAPH_MIN_LANES`, but `project` is a + // pure function anyone can call: a one-column cap has to swallow every + // lane rather than saturating into a negative index. + for lane in 0u16..8 { + assert_eq!(project(lane, 1), 0); + } + } + + #[test] + fn lane_centres_rise_and_land_on_device_pixels() { + for scale in [1.0f32, 1.25, 2.0, 3.0] { + let mut previous = f32::MIN; + for column in 0..GRAPH_MAX_LANES as Lane { + let x = lane_center_x(column, scale); + assert!(x > previous, "column {column} did not advance at {scale}x"); + previous = x; + let physical = x * scale; + assert!( + (physical - physical.round()).abs() < 1e-4, + "column {column} at {scale}x sits at {physical} device pixels" + ); + } + } + // A nonsense scale must not produce NaN geometry. + assert_eq!(lane_center_x(0, 0.), lane_center_x(0, f32::NAN)); + } + + #[test] + fn the_gutter_narrows_with_the_panel() { + // 260px is the default panel; 216px is about as narrow as it gets. + assert_eq!(max_lanes(260.), 5); + assert_eq!(max_lanes(216.), 4); + assert_eq!(max_lanes(320.), 6); + assert_eq!(max_lanes(160.), GRAPH_MIN_LANES); + // The gutter it asks for has to fit inside the share it was given. + for w in [120., 160., 216., 260., 320., 600.] { + let cap = max_lanes(w); + assert!( + cap == GRAPH_MIN_LANES || gutter_width(cap) <= w * GRAPH_GUTTER_SHARE, + "{w}px: a {cap}-lane gutter is {}px of a {}px budget", + gutter_width(cap), + w * GRAPH_GUTTER_SHARE + ); + } + // Below the floor the gutter stops shrinking: three lanes is the least + // that can show a branch leaving and coming back. + assert_eq!(max_lanes(40.), GRAPH_MIN_LANES); + // And above the palette it stops growing, or two columns would share a + // colour. + assert_eq!(max_lanes(4000.), GRAPH_MAX_LANES); + assert!(max_lanes(4000.) <= LANE_SLOTS); + } + + /// The two fixed resize constants are counted in rows against the header. + /// + /// What they are worth is not obvious from their values — 220 is a number, + /// "nine commits and most of a tenth" is a decision — so the decision is + /// what gets pinned, and a change to the row height or the header has to + /// come back through here rather than quietly buying or losing a commit. + /// The partial row at the bottom is deliberate: it is the only thing a + /// fixed-height list says to admit there is more below it, so the assertion + /// is that a real strip of that row survives at both ends. + #[test] + fn the_section_is_sized_in_commits() { + let rows = |h: f32| (h - GRAPH_HEADER_H) / GRAPH_ROW_H; + let resting = rows(GRAPH_H_DEFAULT); + assert!( + (9.5..10.0).contains(&resting), + "the resting section shows {resting} commits" + ); + // In pixels, because "a visible sliver" is a pixel count and not a + // fraction: at 20px rows the last band shows 16 of its 20 and loses 4. + let shown = GRAPH_ROW_H * resting.fract(); + let cut = GRAPH_ROW_H - shown; + assert!( + shown >= 3. && cut >= 3., + "{resting} rows shows {shown}px of the last band and cuts {cut}px, \ + which is not a partial row a reader can see" + ); + let floor = rows(GRAPH_H_MIN); + assert!( + floor >= 3., + "dragged all the way shut the section shows {floor} commits, which \ + is not enough of a graph to be one" + ); + assert!(GRAPH_H_MIN < GRAPH_H_DEFAULT); + // The ceiling is a share of the window, and the floor has to survive a + // window short enough that the share falls under it. + assert_eq!((100f32 * GRAPH_H_MAX_RATIO).max(GRAPH_H_MIN), GRAPH_H_MIN); + } + + /// Every node fits: inside its row, inside the gutter, and clear of the + /// lane line one column over. + /// + /// The widest one is the merge, which is drawn a pixel larger than the + /// others. Nothing about the paint code looks wrong when a node grows past + /// its column — it simply overlaps the neighbouring line — so the fit is + /// asserted here rather than left to be noticed. + #[test] + fn nodes_fit_inside_their_row_and_column() { + let widest = GRAPH_DOT_R + 1.; + assert!(widest * 2. <= GRAPH_ROW_H); + assert!(lane_center_x(0, 1.) - widest >= 0.); + assert!(widest + GRAPH_LINE_W / 2. < GRAPH_LANE_W); + // And a hollow node keeps a hole: a stroke that eats the radius is a + // filled dot wearing a ring's name. + assert!(GRAPH_DOT_R - GRAPH_LINE_W >= 1.); + // The node also has to be worth the row it sits in: a 6px dot in a 20px + // row is 30% of it, and a bead much smaller than that stops reading as + // one on a string. + assert!(GRAPH_DOT_R * 2. >= GRAPH_ROW_H * 0.25); + } + + #[test] + fn conventional_prefixes_come_off_and_prose_does_not() { + assert_eq!( + split_conventional("feat(terminal): localize the menu"), + (Some(("feat", false)), "localize the menu") + ); + assert_eq!( + split_conventional("fix: a thing"), + (Some(("fix", false)), "a thing") + ); + assert_eq!( + split_conventional("feat!: drop the old dialect"), + (Some(("feat", true)), "drop the old dialect") + ); + assert_eq!( + split_conventional("refactor(ui/scm)!: one entry point"), + (Some(("refactor", true)), "one entry point") + ); + for prose in [ + "no prefix here", + // Capitalised is not a conventional type. + "Merge pull request #1 from x", + // No space after the colon. + "fix:it", + // A bare URL must not be cut at its scheme. + "see https://example.invalid for why", + // An empty scope is malformed, not a prefix. + "feat(): nothing", + // Nothing left after the colon is not a subject. + "chore: ", + "", + ] { + assert_eq!( + split_conventional(prose), + (None, prose), + "{prose:?} should have been left alone" + ); + } + } + + #[test] + fn a_chinese_subject_survives_the_split_intact() { + // Byte indexing over a multibyte subject is exactly how this goes + // wrong, so the assertion is on the value, not on not panicking. + let subject = "修复终端右键菜单的本地化"; + assert_eq!(split_conventional(subject), (None, subject)); + let prefixed = "fix(terminal): 修复终端右键菜单的本地化"; + assert_eq!( + split_conventional(prefixed), + (Some(("fix", false)), "修复终端右键菜单的本地化") + ); + } + + /// The prefix carries one bit of colour, not six. + /// + /// A green `feat` beside a green lane dot was exactly the noise this row + /// was cleaned up to lose, and `type_tone` no longer being handed the type + /// is most of the guard against it coming back. What is left to pin is the + /// one exception: a breaking change has to stay visibly apart from + /// everything else, or the exception is not worth making. + #[gpui::test] + fn only_a_breaking_prefix_gets_a_colour(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (_app, mut vcx) = harness(cx); + + vcx.update(|_, cx| { + assert_eq!(type_tone(false, cx), cx.theme().muted_foreground); + assert_eq!(type_tone(true, cx), cx.theme().danger); + assert_ne!( + type_tone(true, cx), + type_tone(false, cx), + "a `!` that looks like every other prefix is not a warning" + ); + }); + } + + /// `4 → node 0 → 4` should be one line bending, not two lines drawn twice. + #[test] + fn a_band_keeps_one_segment_per_column() { + let row = GraphRow { + node: 0, + color: 0, + parents: 2, + edges: smallvec![ + Edge::Pass { lane: 5, color: 5 }, + Edge::Pass { lane: 7, color: 7 }, + Edge::In { from: 0, color: 0 }, + Edge::Out { to: 0, color: 0 }, + Edge::Out { to: 6, color: 6 }, + ], + }; + // Cap of three: lanes 5, 6 and 7 all fall into column 2. + let band = band_of(&row, 3, true, &lanes()); + assert_eq!(band.top[0], Some(lanes().ink[0])); + assert_eq!(band.top[2], Some(lanes().overflow)); + assert_eq!(band.bottom[0], Some(lanes().ink[0])); + assert_eq!(band.bottom[2], Some(lanes().overflow)); + assert_eq!(band.top[1], None); + // Three lanes bundled into the overflow column produce exactly one + // turn, not three stacked on each other. + assert_eq!(band.turns.len(), 1); + assert_eq!(band.turns[0].0, 0); + assert_eq!(band.turns[0].1, 2); + } + + #[test] + fn a_wide_page_only_neutralises_the_column_that_is_shared() { + let l = lanes(); + // Not overflowing: every column is a real lane and keeps its hue. + assert_eq!(column_ink(2, 2, 3, false, &l), l.ink[2]); + // Overflowing: only the last column goes neutral. + assert_eq!(column_ink(2, 2, 3, true, &l), l.overflow); + assert_eq!(column_ink(1, 1, 3, true, &l), l.ink[1]); + // A colour index past the palette can only come from a lane that was + // projected into the bundle, but clamp anyway rather than panic. + assert_eq!(column_ink(0, 99, 3, false, &l), l.ink[LANE_SLOTS - 1]); + } + + #[gpui::test] + fn folding_the_history_section_survives_a_restart(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // It starts shut: a panel that unfurls two hundred commits the first + // time it is opened looks like a mess nobody asked for. + assert!(!app.read_with(&vcx, |app, _| app.scm.graph.expanded)); + app.update(&mut vcx, |app, cx| app.scm_toggle_graph(cx)); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.expanded)); + assert!(vcx.update(|_, cx| { + cx.global::() + .scm_graph_expanded + })); + } + + #[gpui::test] + fn opening_a_row_hands_that_commit_to_the_detail_view(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // Unseeded first: a click that cannot hand a commit over still opens + // the view, and the read that fills it is dispatched later. + app.update(&mut vcx, |app, cx| { + app.graph_open_commit(repo(), "deadbeef".into(), None, cx) + }); + let (selected, detail) = app.read_with(&vcx, |app, _| { + (app.scm.graph.selected.clone(), app.scm.detail.clone()) + }); + assert_eq!(selected.as_deref(), Some("deadbeef")); + let detail = detail.expect("the row opened a commit"); + assert_eq!(detail.oid, "deadbeef"); + assert_eq!(detail.repo, repo()); + assert!(detail.commit.is_none(), "nothing was handed over"); + + // Seeded: the row draws from a commit it already holds, so the detail + // view opens with it and only the file list is left to read. This is + // the path every real click takes. + let seed = commit_named("a subject", "someone", "deadbeef"); + app.update(&mut vcx, |app, cx| { + app.graph_open_commit(repo(), "deadbeef".into(), Some(seed.clone()), cx) + }); + let detail = app + .read_with(&vcx, |app, _| app.scm.detail.clone()) + .expect("the row opened a commit"); + assert_eq!( + detail.commit.as_deref(), + Some(&seed), + "the seed spares the view a `git show`" + ); + } + + /// Changing what the graph walks has to throw the page away, not page on + /// top of it: the rows of a different scope are a different history. + #[gpui::test] + fn switching_scope_resets_the_page_and_its_size(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + app.update(&mut vcx, |app, cx| { + app.scm.graph.requested = GRAPH_PAGE * 3; + app.scm.graph.page = Some(Arc::new(empty_page())); + app.scm.graph.page_key = Some((repo(), 7, GraphScope::HeadAndUpstream)); + app.graph_set_scope(GraphScope::All, cx); + }); + app.read_with(&vcx, |app, _| { + assert_eq!(app.scm.graph.scope, GraphScope::All); + assert_eq!(app.scm.graph.requested, GRAPH_PAGE); + assert!(app.scm.graph.page.is_none()); + assert!(app.scm.graph.page_key.is_none()); + }); + + // Picking the scope that is already showing must not throw the page + // away, or every menu open would cost a `git log`. + app.update(&mut vcx, |app, cx| { + app.scm.graph.page = Some(Arc::new(empty_page())); + app.graph_set_scope(GraphScope::All, cx); + }); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.page.is_some())); + } + + /// One click at the cap used to loop: `requested` grew past what + /// `load_page` clamps to, the answer never satisfied `requested >= want`, + /// and the panel refetched the full page from every frame's render. + #[test] + fn load_more_growth_stops_at_the_cap() { + assert_eq!(next_page_request(0), GRAPH_PAGE * 2); + assert_eq!(next_page_request(GRAPH_PAGE), GRAPH_PAGE * 2); + assert_eq!( + next_page_request(MAX_GRAPH_COMMITS - 1), + MAX_GRAPH_COMMITS, + "the last step lands on the cap, not past it" + ); + assert_eq!( + next_page_request(MAX_GRAPH_COMMITS), + MAX_GRAPH_COMMITS, + "at the cap the click is a no-op, not a bigger ask" + ); + } + + /// A repository switch drops the old page before anything can draw it or + /// grow from it: a stale row's context menu would otherwise build an op + /// for the new repository with the old repository's rev. + #[gpui::test] + fn switching_repositories_drops_the_previous_page(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + let other = RepoKey { + host: HostId::LOCAL, + root: PathBuf::from("/tmp/tty7-graph-test-other"), + }; + app.update(&mut vcx, |app, cx| { + app.scm.graph.requested = GRAPH_PAGE * 3; + app.scm.graph.page = Some(Arc::new(empty_page())); + app.scm.graph.page_key = Some((repo(), 7, GraphScope::HeadAndUpstream)); + app.scm_load_graph(&other, cx); + }); + app.read_with(&vcx, |app, _| { + assert!(app.scm.graph.page.is_none(), "the old history is gone"); + assert!(app.scm.graph.page_key.is_none()); + assert_eq!( + app.scm.graph.requested, 0, + "page depth does not carry across repositories" + ); + }); + + // The same repository keeps its page — this must not turn every + // render into a reload. + app.update(&mut vcx, |app, cx| { + app.scm.graph.page = Some(Arc::new(empty_page())); + app.scm.graph.page_key = Some((repo(), 7, GraphScope::HeadAndUpstream)); + app.scm_load_graph(&repo(), cx); + }); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.page.is_some())); + } + + #[test] + fn the_filter_matches_what_a_row_can_show() { + let commit = commit_named("feat(ui): the graph", "Ada Lovelace", "c0ffee1234"); + for hit in ["graph", "GRAPH", "feat", "ada", "Lovelace", "c0ffee"] { + assert!( + matches_query(&commit, &hit.to_lowercase()), + "{hit:?} should have matched" + ); + } + // The body is deliberately not searched: a hit the row cannot show + // looks like a wrong result. + assert!(!matches_query(&commit, "rationale")); + // A sha matches as a prefix, the way `git show` takes one — not as a + // substring, or every query of hex characters would light up. + assert!(!matches_query(&commit, "ffee")); + } + + /// A filter you cannot see must not be filtering. + /// + /// The field is behind a tile now, and the failure mode a hidden filter + /// invites is a reader staring at a list with rows missing and nothing on + /// screen to say why. The guard is structural — closing drops the + /// `InputState`, and `graph_query` has nowhere else to read text from — so + /// this is the test that the structure holds. + #[gpui::test] + fn closing_the_filter_takes_its_query_with_it(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // At rest there is no field at all, which is what the section looks + // like every time it is opened. + app.update(&mut vcx, |app, cx| { + assert!(app.scm.graph.search.is_none()); + assert!(app.graph_query(cx).is_none()); + }); + + app.update_in(&mut vcx, |app, window, cx| { + app.graph_toggle_search(window, cx); + assert!(app.scm.graph.search.is_some(), "the tile opens the field"); + assert!( + app.scm.graph.search_sub.is_some(), + "and wires typing to a repaint, or the list never sees the text" + ); + }); + + app.update_in(&mut vcx, |app, window, cx| { + let input = app.scm.graph.search.clone().expect("the field is open"); + input.update(cx, |state, cx| state.set_value("Feat", window, cx)); + }); + assert_eq!( + app.update(&mut vcx, |app, cx| app.graph_query(cx)), + Some("feat".to_string()), + "the box filters while it is open" + ); + + app.update_in(&mut vcx, |app, window, cx| { + app.graph_toggle_search(window, cx) + }); + app.update(&mut vcx, |app, cx| { + assert!(app.scm.graph.search.is_none()); + assert!(app.scm.graph.search_sub.is_none()); + assert!( + app.graph_query(cx).is_none(), + "a closed box went on cutting rows out of the list" + ); + }); + } + + #[test] + fn the_scope_button_says_which_history_is_showing() { + assert_eq!( + scope_label(&GraphScope::HeadAndUpstream), + t(L10nKey::ScmGraphCurrentBranch) + ); + assert_eq!( + scope_label(&GraphScope::All), + t(L10nKey::ScmGraphAllBranches) + ); + // The label is the branch, not the fully qualified ref: `refs/heads/` + // is eleven characters of the panel spent saying nothing. + assert_eq!( + scope_label(&GraphScope::Refs(vec!["refs/heads/feature/auth".into()])), + "auth" + ); + } + + fn commit_named(summary: &str, author: &str, oid: &str) -> Commit { + use tty7_core::core::git::log::{OffsetTs, Signature}; + let who = Signature { + name: author.to_string(), + email: "a@b.invalid".into(), + at: OffsetTs { + unix: 0, + offset_minutes: 0, + }, + }; + Commit { + oid: oid.to_string(), + parents: smallvec![], + author: who.clone(), + committer: who, + summary: summary.to_string(), + body: "rationale goes in the body".into(), + refs: Vec::new(), + } + } + + fn empty_page() -> CommitPage { + CommitPage { + commits: Vec::new(), + rows: Vec::new(), + max_lanes: 0, + scope: GraphScope::HeadAndUpstream, + requested: GRAPH_PAGE, + complete: true, + truncated_lanes: false, + open_lanes: Vec::new(), + } + } +} + +/// The one test that has to run against a real repository and a real pane. +/// +/// A canvas that repaints every frame would make the panel a perpetual motion +/// machine, and nothing about the code reads as wrong when it does — the only +/// way to know is to settle the window and count frames. Same shape as the file +/// tree's own idle tests, including the serial lock: the render probe is +/// thread-local and two of these at once would count each other's frames. +/// +/// `unix` for the same reason `panel.rs`, `detail.rs` and `file_tree.rs` gate +/// theirs: a real pane means `test_window::harness_with_pane`, and that harness +/// hands back a `std::os::unix::net::UnixStream`. +#[cfg(all(test, unix))] +mod render_idle_gpui_tests { + use super::*; + use crate::ui::app::{render_probe, test_window}; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use std::path::Path; + use tty7_core::core::config::RightPanelTab; + + const BUDGET: u64 = 200; + + fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Git with the identity and signing pinned, so the test does not depend on + /// whatever is in the developer's `~/.gitconfig`. + fn git(root: &Path, args: &[&str]) -> bool { + let mut full = vec![ + "-c", + "user.name=tty7 test", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + ]; + full.extend_from_slice(args); + std::process::Command::new("git") + .args(&full) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-graph-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(&dir).unwrap() + } + + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { + render_probe::arm(BUDGET); + 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(BUDGET); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(9)); + vcx.background_executor.run_until_parked(); + render_probe::draws() + } + + /// Drive frames until the graph has a page, because the query only goes out + /// from `render`. + fn settle_graph(app: &Entity, vcx: &mut VisualTestContext) -> Option> { + 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(); + let page = app.update_in(vcx, |app, _, _| app.scm.graph.page.clone()); + if page.is_some() { + vcx.background_executor.run_until_parked(); + return page; + } + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + #[gpui::test] + fn an_expanded_graph_with_history_reaches_render_idle(cx: &mut TestAppContext) { + let _serial = serial(); + crate::core::config::pin_test_config_dir(); + let root = scratch("idle"); + if !git(&root, &["init", "--quiet"]) { + return; // no git on this machine + } + for n in 0..6 { + std::fs::write(root.join(format!("f{n}.txt")), format!("{n}\n")).unwrap(); + assert!(git(&root, &["add", "-A"])); + assert!(git( + &root, + &["commit", "--quiet", "-m", &format!("feat(x): commit {n}")] + )); + } + + let (app, mut vcx, _pane) = test_window::harness_with_pane(cx); + crate::daemon::protocol::DaemonMsg::Cwd(root.clone()) + .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; + app.scm.graph.expanded = true; + cx.notify(); + }); + + let Some(page) = settle_graph(&app, &mut vcx) else { + // A machine where the pane never reported its cwd has nothing to + // say about idling; failing here would only be flaky. + let _ = std::fs::remove_dir_all(&root); + return; + }; + assert!(page.commits.len() >= 6, "the graph loaded no history"); + assert_eq!(page.rows.len(), page.commits.len()); + + assert_eq!(draws_while_idle(&mut vcx), 0); + // And it is still expanded and still holding the same page, i.e. the + // zero above is idleness and not the section having quietly vanished. + app.update_in(&mut vcx, |app, _, _| { + assert!(app.scm.graph.expanded); + assert!(app.scm.graph.page.is_some()); + assert!(!app.scm.graph.loading); + }); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs new file mode 100644 index 00000000..50f4f4e9 --- /dev/null +++ b/src/ui/scm/mod.rs @@ -0,0 +1,19 @@ +//! Source control: the panel, its file rows, the commit box, and the graph. +//! +//! Every file here hangs `impl Tty7App` blocks, the same shape `sftp.rs` and +//! `file_tree.rs` use. The directory only keeps the surface from piling into +//! `right_panel.rs`. + +// The graph and the commit detail view both have callers now, so their allows +// are gone. What is left is `status_rank`, which is the file tree's to use. +pub(crate) mod actions; +pub(crate) mod detail; +pub(crate) mod graph; +pub(crate) mod panel; +pub(crate) mod path; +pub(crate) mod state; +#[allow(dead_code)] +pub(crate) mod status; + +pub(crate) use actions::ScmIntent; +pub(crate) use state::{GraphState, ScmPanelState}; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs new file mode 100644 index 00000000..31cf96b9 --- /dev/null +++ b/src/ui/scm/panel.rs @@ -0,0 +1,2930 @@ +//! The source control panel body: four groups of file rows over the working +//! tree status, in the order git itself talks about them. +//! +//! The panel is rendered from `WorkingTreeStatus`, which knows the difference +//! between the index and the working tree. That is the whole reason the old +//! flat list had to go: it ran one `git diff HEAD`, so it could not tell a +//! staged change from an unstaged one and showed every row the letter `M`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{AnyElement, Context, Focusable as _, SharedString, Window, div, prelude::*, px}; +use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _}; +use gpui_component::input::{Input, InputState}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, h_flex, v_flex, +}; + +use tty7_core::core::git::diff::MAX_RENDERED_FILES; +use tty7_core::core::git::ops::GitOp; +use tty7_core::core::git::status::{ + ChangeCode, DecoStatus, HeadState, RepoOperation, RepoPath, StatusEntry, WorkingTreeStatus, +}; + +use crate::terminal::git_data::status_of; +use crate::terminal::git_diff::DiffSource; +use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App}; +use crate::ui::host_ops::{HostId, SharedHost}; +use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; +use crate::ui::right_panel::{SEARCH_H, git_badge, info_chip}; +use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners}; +use crate::ui::scm::ScmIntent; +use crate::ui::scm::path::{elide_middle, split_display_path}; +use crate::ui::scm::state::{RepoKey, ScmGroup}; +use crate::ui::scm::status::{status_color, status_glyph}; + +/// A file row, and the group header above it. Both 24px, so the list reads as +/// one grid rather than as headers with a list hanging off them. +/// +/// It is also the height `scm/detail.rs` gives the changed-file rows it shows +/// for a commit — the two lists are the same list pointed at different trees, +/// and a reader who opens a commit must not feel the pitch change under them. +const ROW_H: f32 = 24.; + +/// The status letter's column, from `git_badge`. The group chevron sits in a +/// box of exactly this width so the two line up in one column down the panel. +/// +/// This is `right_panel::BADGE_W` spelled out where the header can read it, +/// and the test below pins the two together. They are one column, and a column +/// drawn from two numbers is a column that will eventually be drawn from two +/// different numbers. +const BADGE_W: f32 = 14.; + +/// Rows are laid out inside this inset and then pad themselves back out, so a +/// hovered row's background is wider than its text on both sides. +/// +/// The text lands on `CONTENT_INSET` whatever this is — the list subtracts it +/// outside the row and the row adds it back inside — so all this number sets +/// is how far the hover fill bleeds past the text. `scm/detail.rs` carries the +/// same pair for the same reason. +const ROW_INSET: f32 = 4.; + +/// The key context the message box installs, and the one `ScmCommit` is +/// bound inside. The two are the same string on purpose: a binding whose +/// context nothing attaches is a binding that never fires. +pub(crate) const COMMIT_KEY_CONTEXT: &str = "ScmCommit"; + +/// Past this many, the branch switcher scrolls instead of growing. +const BRANCHES_IN_MENU: usize = 12; + +/// How much of a branch name survives the row. Long names carry their +/// meaning at both ends (`feature/…/auth-retry`), so the middle is what goes. +const BRANCH_NAME_CHARS: usize = 24; + +/// Untracked files past this many start folded. A fresh clone of a repository +/// with a stale `.gitignore` can put thousands of them in front of the three +/// changes the user came to look at. +const UNTRACKED_AUTO_COLLAPSE: usize = 20; + +/// The message box at rest, the ceiling it grows to, and the padding inside +/// its fill. +/// +/// The box is a soft rounded fill with no outline, which is what +/// `switcher.rs`'s inline rename field already does and the only shape in the +/// app for "an input you write into rather than filter with". Every `Input` in +/// tty7 is `.appearance(false)`; a hairline here, and an accent ring on top of +/// it when focused, made the commit box the one outlined thing on a panel that +/// otherwise separates by surface and space alone. The fill it wears instead is +/// [`field_fill`], deliberately below the ramp's first rung. +/// +/// The box still has to end up exactly [`SEARCH_H`] tall, which is what makes +/// every input row in the panel sit on one line, and getting there is +/// arithmetic rather than a guess: +/// +/// * gpui-component lays an `Input`'s text out at a fixed `Rems(1.25)` line +/// height, which at the default 16px rem is `MSG_LINE` = 20px whatever size +/// the field is set to. +/// * At `.xsmall()` the field adds no vertical padding of its own — `input_py` +/// is 0 there — so one row of field measures exactly `MSG_LINE`. +/// * gpui measures border-box. With the hairline gone its 1px each side has to +/// come out of the padding or the box would shrink to 28: `5 + 20 + 5` = 30, +/// and the assertion below refuses to compile if that stops matching the +/// search strip. +/// +/// `MSG_PAD_X` absorbs the same lost pixel — 9 against `input_px`'s 4 at +/// `.xsmall()`, and the two paddings add to the thirteen the hairline version +/// also reached, which is where the panel's text column is. +const MSG_LINE: f32 = 20.; +const MSG_PAD_X: f32 = 9.; +const MSG_PAD_Y: f32 = 5.; +const MSG_MIN_H: f32 = MSG_PAD_Y + MSG_LINE + MSG_PAD_Y; +/// The ceiling, as `MSG_ROWS_MAX` bare lines. It is a rail, not a border-box +/// sum: the wrapper's own hairline and padding are not in it, so at the very +/// top of the box's growth the last row is clipped rather than framed. +const MSG_MAX_H: f32 = MSG_ROWS_MAX as f32 * MSG_LINE; + +/// The invariant the comment above describes, made unbreakable: the resting +/// message box and the panel's search strip are the same height, or this does +/// not build. +const _: () = assert!(MSG_MIN_H == SEARCH_H); + +/// One line at rest and it grows into the message. The box is one row in a +/// column of rows, and a box that stands four lines tall before anything has +/// been typed pushes the file list — the thing the panel is for — off the +/// bottom of a 260px-wide sidebar. +const MSG_ROWS: usize = 1; +const MSG_ROWS_MAX: usize = 6; + +/// The commit control: one frame, the label button and the chevron inside it, +/// and the glyph in the chevron. +/// +/// A joined split control, sized like the panel's other controls rather than +/// stretched across it — the row it sits in reads "N staged" on the left and +/// offers this on the right, so it only has to be as wide as its label. +/// +/// The frame is a hairline around a 22px interior, which puts it at 24 — +/// gpui measures border-box, and the hairline is part of what a reader sees. +/// Twenty-four is the panel's row pitch, so the control sits on the same line +/// grid as everything above and below it. +/// +/// The chevron half is a 22px cell, so the divider falls where the eye expects +/// it rather than a couple of pixels early. +/// +/// `COMMIT_GLYPH` is the group chevron's size: a caret is an affordance, not +/// content — it says "there is more here", and it says it at the same size as +/// every other small mark in the panel. +const COMMIT_H: f32 = 24.; +const COMMIT_CHEVRON_W: f32 = 22.; +const COMMIT_GLYPH: f32 = 11.; + +/// How long to wait before asking git again about a directory that answered +/// with nothing. +/// +/// `scm_refresh` is safe to call every frame — it de-duplicates in-flight +/// probes and skips fresh ones. What it cannot do is notice that a probe came +/// back empty: a repository we never got a status for stays stale forever, so +/// without this the panel would start a new `git status` on every frame. +const PROBE_RETRY: Duration = Duration::from_secs(2); + +/// How long "there is no repository here" is believed for. Long enough that +/// sitting in `/tmp` costs nothing, short enough that `git init` in the pane +/// below shows up without touching anything. +const NOT_A_REPO_RETRY: Duration = Duration::from_secs(10); + +/// What the panel knows about the directory the active pane is sitting in. +enum RepoLookup { + /// Nothing has answered yet — the tab's own probe is still out. + Pending, + NotARepo, + Root(PathBuf), +} + +impl Tty7App { + pub(crate) fn render_panel_scm( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + self.scm_watch_status(cx); + // An explicit repository pick should outlive a pane switch inside the + // tab it was made on, and not a jump to a different tab. + if self.scm.override_tab != Some(self.active) { + self.scm.repo_override = None; + self.scm.override_tab = None; + } + + let Some((host, cwd)) = self.scm_pane_target(window, cx) else { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty( + t(L10nKey::PanelNoWorkingDirectory), + Some(t(L10nKey::PanelNoWorkingDirectoryHint)), + cx, + ); + return self.scm_shell(title, body); + }; + + let root = match self.scm_repo_root(&host, &cwd, cx) { + RepoLookup::Pending => { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty(t(L10nKey::PanelLoading), None, cx); + return self.scm_shell(title, body); + } + RepoLookup::NotARepo => { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty( + t(L10nKey::PanelNotAGitRepo), + Some(t(L10nKey::PanelNotAGitRepoHint)), + cx, + ); + return self.scm_shell(title, body); + } + RepoLookup::Root(root) => root, + }; + + self.scm.repo = Some(RepoKey { + host: host.id(), + root, + }); + // An override whose host has left the registry is dropped, not worked + // around: falling back to the *pane's* host while keeping the + // override's root would probe the wrong machine for that path and + // cache the answer under a mismatched key. + if self + .scm + .repo_override + .as_ref() + .is_some_and(|k| crate::ui::host_registry::HostRegistry::get(cx, k.host).is_none()) + { + self.scm.repo_override = None; + self.scm.override_tab = None; + } + // An explicit pick from the switcher wins over the pane's own + // repository, so everything below reads through `active_repo`. + let repo = self + .scm + .active_repo() + .cloned() + .expect("the pane's repository was just recorded"); + let host = match crate::ui::host_registry::HostRegistry::get(cx, repo.host) { + Some(host) => host, + None => host, + }; + self.scm_probe(&host, &repo.root, cx); + let Some(status) = self.scm_seen_status(repo.host, &repo.root, cx) else { + let title = self.panel_title(t(L10nKey::PanelScmTitle), None, None, window, cx); + let body = self.panel_empty(t(L10nKey::PanelLoading), None, cx); + return self.scm_shell(title, body); + }; + + let count = (status.total_entries > 0).then(|| status.total_entries.to_string()); + let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); + + let branch = self.scm_branch_row(&repo, &status, cx); + let naming = self.scm_new_branch_row(&repo, cx); + let commit = self.scm_commit_box(&repo, &status, window, cx); + let buttons = self.scm_commit_buttons(&repo, &status, cx); + let mut pinned = vec![branch]; + pinned.extend(naming); + pinned.push(commit); + pinned.push(buttons); + // A commit's detail replaces the working tree's, so the two can never + // be on screen claiming to be the same thing. + let detail = self.scm.detail.clone(); + let body = match detail + .as_ref() + .and_then(|d| self.render_commit_detail(d, window, cx)) + { + Some(body) => body, + None if status.is_clean() => self.panel_empty( + t(L10nKey::PanelNoChanges), + Some(t(L10nKey::PanelNoChangesHint)), + cx, + ), + None => self.scm_groups(&repo, &status, cx), + }; + let history = self.render_graph_section(&repo, window, cx); + self.scm_shell_full(title, pinned, body, history) + } + + /// The repository line: which branch, how far from its upstream, and one + /// button to close the gap. + /// + /// A row of its own rather than the title's trailing slot. Off macOS the + /// tab tiles render *after* that slot, and a branch name is the elastic + /// element here — it would be the first thing squeezed. `render_sftp_ + /// breadcrumb` sets the same precedent. + fn scm_branch_row( + &mut self, + repo: &RepoKey, + status: &WorkingTreeStatus, + cx: &mut Context, + ) -> AnyElement { + self.scm_load_branches(repo, cx); + let mono = cx.theme().mono_font_family.clone(); + let theme = cx.theme(); + let (warning, muted, fg) = (theme.warning, theme.muted_foreground, theme.foreground); + let detached = matches!(status.head, HeadState::Detached { .. }); + let busy = self.scm_network_busy(repo, cx); + let others = self.scm_other_repos(repo); + + h_flex() + .flex_none() + .items_center() + .gap(px(6.)) + // Taller than a file row, and what sets it is the `TILE_SIZE_SM` + // sync tile at the end plus 2px of air — not the type, which is a + // step under it. + .h(px(28.)) + .pl(px(CONTENT_INSET)) + .pr(px(crate::ui::app::tile_trailing_inset_sm())) + .child( + Icon::empty() + .path("icons/git-branch.svg") + .size(px(12.)) + .text_color(muted), + ) + // The trigger is a `Button` because that is the one element the + // dropdown trait is implemented for. `dropdown_caret` turns its + // label row into `justify_between`, which is what puts the name on + // the left and the chevron against the notes. + // + // The branch is the most important word on the row, and it earns + // that by being in full-strength ink — not by being set larger + // than the file names underneath it. + .child( + Button::new("scm-branch") + .ghost() + .xsmall() + .dropdown_caret(true) + .label(elide_middle(&head_label(&status.head), BRANCH_NAME_CHARS).to_string()) + .flex_1() + .min_w(px(0.)) + .h(px(20.)) + .rounded(px(5.)) + .text_color(fg) + .when(detached, |s| s.font_family(mono.clone())) + .dropdown_menu_with_anchor( + gpui::Anchor::TopLeft, + self.scm_branch_menu(repo, status, cx), + ), + ) + .children(others.map(|count| branch_note(&format!("+{count}"), muted, &mono))) + .when(detached, |this| { + this.child(info_chip( + t(L10nKey::ScmDetached), + warning.opacity(0.16), + warning, + &mono, + )) + }) + .children(status.operation.map(|op| { + info_chip( + t(operation_label(op)), + warning.opacity(0.16), + warning, + &mono, + ) + })) + // Armed-amend is a mode you can forget you are in, which puts it in + // the same class as `detached` and a rebase in progress above — so + // it gets their tint, not a chip of its own invention. + .when(self.scm.amend, |this| { + this.child(info_chip( + t(L10nKey::ScmAmendBadge), + warning.opacity(0.16), + warning, + &mono, + )) + }) + .children( + tracking_chip(status.upstream.as_deref(), status.ahead_behind) + .map(|text| branch_note(&text, muted, &mono)), + ) + .child( + crate::ui::tab_strip::chrome_tile_sized( + Button::new("scm-sync").icon(if busy { + Icon::new(IconName::LoaderCircle) + } else { + Icon::empty().path("icons/git-sync.svg") + }), + crate::ui::app::TILE_SIZE_SM, + crate::ui::app::TILE_GLYPH_SM, + false, + cx, + ) + .rounded_md() + .disabled(busy) + .tooltip(if status.upstream.is_some() { + t(L10nKey::ScmSync) + } else { + t(L10nKey::ScmPublishBranch) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.run_scm_action(ScmIntent::Sync, window, cx); + })), + ) + .into_any_element() + } + + /// Whether a network operation against this repository is still running. + /// + /// Asks the slot counter `run_git_op` claims from, which is the operation + /// itself rather than a proxy for it: the epoch moves for a `.git` event + /// or a file save too, so watching that would drop the spinner in the + /// middle of a slow push. + /// + /// Read through `try_global`, never `default_global` — this runs from + /// `render`, and taking the global mutably there queues a global-observer + /// effect on every frame. + fn scm_network_busy(&self, repo: &RepoKey, cx: &gpui::App) -> bool { + cx.try_global::() + .is_some_and(|data| data.network_slots(repo.host, &repo.root) > 0) + } + + /// How many repositories other than this one the panel could switch to. + fn scm_other_repos(&self, current: &RepoKey) -> Option { + let choices = self.scm_repo_choices(); + let count = choices.len().saturating_sub(1); + (count > 0 && choices.contains(current)).then_some(count) + } + + /// Read the local branch names, at most once per epoch. + fn scm_load_branches(&mut self, repo: &RepoKey, cx: &mut Context) { + let epoch = cx + .default_global::() + .epoch(repo.host, &repo.root); + if self + .scm + .branches + .get(repo) + .is_some_and(|(at, _)| *at == epoch) + || self.scm.branches_loading.contains(repo) + { + return; + } + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, repo.host) else { + return; + }; + self.scm.branches_loading.insert(repo.clone()); + let root = repo.root.clone(); + let key = repo.clone(); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| { + // `for-each-ref` rather than `branch`: no porcelain warnings, + // no column layout, and one name per line whatever the config. + tty7_core::core::git::git( + h, + &root, + &["for-each-ref", "--format=%(refname:short)", "refs/heads"], + ) + }, + move |this, out, cx| { + this.scm.branches_loading.remove(&key); + let names = out + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + this.scm.branches.insert(key, (epoch, names)); + cx.notify(); + }, + ); + } + + fn scm_branch_menu( + &self, + repo: &RepoKey, + status: &WorkingTreeStatus, + cx: &mut Context, + ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static + use<> + { + let app = cx.entity().downgrade(); + let repo = repo.clone(); + let current = match &status.head { + HeadState::Branch { name, .. } | HeadState::Unborn { branch: name } => name.clone(), + HeadState::Detached { .. } => String::new(), + }; + let branches = self + .scm + .branches + .get(&repo) + .map(|(_, names)| names.clone()) + .unwrap_or_default(); + let others = self.scm_repo_choices(); + + move |menu, _window, _cx| { + let mut menu = menu.min_w(px(200.)); + // Past a dozen the list stops being scannable, so it scrolls + // rather than growing taller than the window. + if branches.len() > BRANCHES_IN_MENU { + menu = menu.scrollable(true).max_h(px(300.)); + } + for name in &branches { + let is_current = *name == current; + menu = menu.item( + PopupMenuItem::new(name.clone()) + .checked(is_current) + .disabled(is_current) + .on_click({ + let app = app.clone(); + let repo = repo.clone(); + let name = name.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm_op( + repo.clone(), + GitOp::CheckoutBranch { name: name.clone() }, + window, + cx, + ) + }); + } + }), + ); + } + menu = menu + .separator() + .item(PopupMenuItem::new(t(L10nKey::ScmCreateBranch)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.scm_begin_create_branch(window, cx)); + } + })); + for (label, intent) in [ + (L10nKey::ScmFetch, ScmIntent::Fetch), + (L10nKey::ScmPull, ScmIntent::Pull), + (L10nKey::ScmPush, ScmIntent::Push), + ] { + menu = menu.item(PopupMenuItem::new(t(label)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.run_scm_action(intent, window, cx)); + } + })); + } + if others.len() > 1 { + menu = menu + .separator() + .item(PopupMenuItem::label(t(L10nKey::ScmSwitchRepository))); + for other in &others { + let label = other + .root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| other.root.display().to_string()); + menu = menu.item(PopupMenuItem::new(label).checked(*other == repo).on_click({ + let app = app.clone(); + let other = other.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm.repo_override = Some(other.clone()); + this.scm.override_tab = Some(this.active); + cx.notify(); + }); + } + })); + } + } + menu + } + } + + /// Every repository the panel has looked at this session. + /// + /// Built from the roots it resolved rather than from the open tabs, + /// because only a resolved root is safe to act on: the cheap per-tab cache + /// knows a repository's *home*, which is a different directory inside a + /// linked worktree, and running an operation in the wrong tree is worse + /// than not offering the switch. + fn scm_repo_choices(&self) -> Vec { + let mut out: Vec = Vec::new(); + for ((host, _cwd), (_, root)) in &self.scm.roots { + let Some(root) = root else { continue }; + let key = RepoKey { + host: *host, + root: root.clone(), + }; + if !out.contains(&key) { + out.push(key); + } + } + out.sort_by(|a, b| a.root.cmp(&b.root)); + out + } + + pub(crate) fn scm_begin_create_branch(&mut self, window: &mut Window, cx: &mut Context) { + let input = + cx.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::ScmCreateBranch))); + let handle = input.read(cx).focus_handle(cx); + self.scm.new_branch = Some(input); + window.focus(&handle, cx); + cx.notify(); + } + + /// The inline "name your branch" row. + /// + /// A text input rather than a dialog: `window.prompt` can only offer + /// buttons, and the project has no modal component to reach for. The file + /// tree names new files the same way. + fn scm_new_branch_row(&mut self, repo: &RepoKey, cx: &mut Context) -> Option { + let input = self.scm.new_branch.clone()?; + let repo = repo.clone(); + Some( + h_flex() + .id("scm-new-branch") + .flex_none() + .items_center() + // Every input row in the panel is 30px, and the field inside + // it is the `.xsmall()` one that height was derived from. + .h(px(30.)) + .px(px(CONTENT_INSET)) + .child(div().flex_1().min_w_0().child(Input::new(&input).xsmall())) + .on_key_down( + cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| { + match ev.keystroke.key.as_str() { + "escape" => { + this.scm.new_branch = None; + cx.notify(); + } + "enter" => { + let Some(input) = this.scm.new_branch.take() else { + return; + }; + let name = input.read(cx).value().trim().to_string(); + cx.notify(); + if name.is_empty() { + return; + } + this.scm_op( + repo.clone(), + GitOp::CreateBranch { + name, + start: None, + checkout: true, + }, + window, + cx, + ); + } + _ => {} + } + }), + ) + .into_any_element(), + ) + } + + /// The message box. + /// + /// `key_context` rather than a focus trap: `secondary-enter` is + /// `ToggleFullscreen` at the window level, and the two coexist because + /// gpui resolves a keystroke by walking outwards from the focused node — + /// the narrow context wins while the box has focus, and the window keeps + /// the chord everywhere else. + /// + /// A hairline and no fill, like every other framed thing in the panel. + /// Focus recolours the same hairline to `theme.ring` rather than adding + /// anything: a border that appears on focus would move the text by a pixel + /// every time the box was clicked, because gpui measures border-box. + fn scm_commit_box( + &mut self, + repo: &RepoKey, + status: &WorkingTreeStatus, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let input = self.scm_commit_input(repo, status, window, cx); + let focused = input.read(cx).focus_handle(cx).is_focused(window); + let sf = cx.global::().sidebar; + div() + .key_context(COMMIT_KEY_CONTEXT) + .flex_none() + .px(px(CONTENT_INSET)) + .pt(px(6.)) + .child( + div() + // `MSG_MIN_H` is `panel_search`'s height and the paddings + // are what get it there; see the constants. The maximum is + // a rail rather than the truth — in auto-grow mode the + // input sizes itself off its row count, so `MSG_ROWS_MAX` + // is what actually stops it, and this holds the shape if it + // ever measures itself differently. + .min_h(px(MSG_MIN_H)) + .max_h(px(MSG_MAX_H)) + .rounded(CARD_RADIUS) + // Half a rung, not a whole one. The hover step is what a + // row wears when the pointer is on it — a transient state — + // and the message box wears its fill all the time, so at + // full strength it read as the loudest thing on an idle + // panel. Focus then takes the whole rung, which keeps the + // two apart without a ring: the caret already says where + // the keystrokes go, and the fill only has to say which + // shape owns them. + .bg(gpui::rgb(match focused { + true => sf.hover, + false => field_fill(sf), + })) + .px(px(MSG_PAD_X)) + .py(px(MSG_PAD_Y)) + // `.xsmall()` is also what sets the box's height, because + // it is the size whose `input_py` is zero — see the + // `MSG_*` constants. + .child(Input::new(&input).appearance(false).xsmall()), + ) + .into_any_element() + } + + /// Hand the box the draft belonging to the repository on screen, and keep + /// whatever is in it under the repository it was typed for. + /// + /// Per repository rather than per tab or per pane: a working tree has one + /// pending message however many panes are looking at it. + fn scm_commit_input( + &mut self, + repo: &RepoKey, + status: &WorkingTreeStatus, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Entity { + let input = match self.scm.commit_input.clone() { + Some(input) => input, + None => { + let input = cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .auto_grow(MSG_ROWS, MSG_ROWS_MAX) + .placeholder(t(L10nKey::ScmCommitPlaceholder)) + }); + self.scm.commit_input = Some(input.clone()); + input + } + }; + let text = input.read(cx).value().to_string(); + + if self.scm.commit_repo.as_ref() != Some(repo) { + if let Some(previous) = self.scm.commit_repo.take() { + self.scm.drafts.insert(previous, text); + } + let next = match self.scm.drafts.get(repo) { + Some(draft) => draft.clone(), + // A merge or a cherry-pick leaves git's own message in + // `.git/MERGE_MSG`; starting from blank would throw away the + // conflict summary the user is about to want. + None => status.prefilled_message.clone().unwrap_or_default(), + }; + input.update(cx, |state, cx| state.set_value(next, window, cx)); + self.scm.commit_repo = Some(repo.clone()); + return input; + } + + if self.scm_commit_landed(repo, status, &text) { + input.update(cx, |state, cx| state.set_value("", window, cx)); + return input; + } + if self.scm.drafts.get(repo).map(String::as_str) != Some(text.as_str()) { + self.scm.drafts.insert(repo.clone(), text); + } + input + } + + /// Whether the commit we dispatched actually happened, and so whether the + /// message may be thrown away. + /// + /// Clearing the box the moment `git commit` is *dispatched* would lose a + /// carefully written message to a pre-commit hook that rejects it. HEAD + /// moving is the one signal that says the message is now in the + /// repository; an edit made in the meantime keeps it too. + fn scm_commit_landed( + &mut self, + repo: &RepoKey, + status: &WorkingTreeStatus, + text: &str, + ) -> bool { + let Some((sent_repo, before, message)) = &self.scm.committing else { + return false; + }; + if sent_repo != repo || *before == status.head || message != text { + return false; + } + self.scm.committing = None; + self.scm.drafts.remove(repo); + true + } + + /// The line under the message box: what is staged on the left, what to do + /// about it on the right. + /// + /// One split control, not a bar across the panel: the label button and the + /// chevron share a single hairline frame with a 1px divider between them, + /// and the pair is only as wide as the label. Right-aligned, with the + /// count it acts on reading along the same line. + /// + /// Ghost inside the frame in both states, so the panel at rest holds no + /// filled slab. The obvious alternative, gpui-component's `.primary()`, + /// paints a grey one: `theme.primary` is `mix(foreground, background, + /// 0.20)` in tty7, so a "primary" button is a mid-grey block sitting in a + /// column of hairlines. What tells the two states apart is the ink — + /// full-strength when there is something to commit, muted when there is + /// not, which is where the panel sits most of the time. + fn scm_commit_buttons( + &self, + repo: &RepoKey, + status: &WorkingTreeStatus, + cx: &mut Context, + ) -> AnyElement { + let plan = commit_plan(status, self.scm.amend, self.scm.draft(repo)); + let repo_for_button = repo.clone(); + let live = plan.enabled; + let staged = status.staged().count(); + let theme = cx.theme(); + let (muted, fg) = (theme.muted_foreground, theme.foreground); + let sf = cx.global::().sidebar; + h_flex() + .flex_none() + .items_center() + .gap(px(8.)) + .px(px(CONTENT_INSET)) + .pt(px(6.)) + .pb(px(8.)) + // The reading the button acts on, in the row it acts from. It + // gives way first: a long count is still a count, and the control + // beside it is the thing that has to keep its shape. + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.)) + .text_color(muted) + .child(t_plural(L10nKey::ScmStagedFileCount, staged, &[])), + ) + .child( + // Filled, not outlined, and for the same reason the message box + // above it is: nothing else in this panel wears a border, and a + // hairline box sitting on the panel's own fill reads as raised + // — a shadow nobody drew. + // + // The frame owns the fill, the rounding *and* the hover, so the + // control lights as one shape. Letting each half light itself + // is what a split button conventionally does, but this one has + // no outline around either half and a seam only a pixel wide: + // half a lit pill reads as a paint bug rather than as "this is + // the part you are on". The halves still carry + // `segment_corners` so their own hit shapes stay inside the + // frame's radius — `overflow_hidden` would do that too, and + // would take the chevron's popup menu with it. + h_flex() + .flex_none() + .items_center() + .h(px(COMMIT_H)) + .rounded(CARD_RADIUS) + .bg(gpui::rgb(field_fill(sf))) + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .child( + // `xsmall` for the same reason the branch trigger is + // `xsmall`: it is the token that sets a `Button`'s + // label to 12px, the panel's own step. The padding and + // the height are set below, so the size token is doing + // nothing here but choosing the type. + Button::new("scm-commit") + // A custom variant, not `.ghost()`: the frame is + // already painted at the ramp's hover rung, and a + // ghost hovers to a colour close enough to it that + // the pointer would get no answer. This walks the + // same two rungs the file rows walk. + .custom(commit_half(cx)) + .xsmall() + .label(t(plan.label)) + .h_full() + .px(px(10.)) + .rounded_corners(segment_corners(0, 2, CARD_RADIUS, HAIRLINE)) + // The ink is named here rather than left to the + // variant, so the disabled half greys out: it lands + // over the variant's own paint because + // `refine_style` runs after everything the variant + // does. + .text_color(if live { fg } else { muted }) + .disabled(!live) + .when(!live, |b| b.tooltip(t(plan.reason))) + .on_click(cx.listener(move |this, _, window, cx| { + this.scm_commit( + repo_for_button.clone(), + this.scm.amend, + None, + window, + cx, + ); + })), + ) + // The seam between the halves is the panel showing through + // the fill, not a line drawn on top of it — a rule would be + // the one stroke left on the panel. A div rather than + // `border_l_1` on the chevron, because a ghost `Button` + // repaints its border colour transparent in every state it + // has and the seam would vanish under the pointer. + .child( + div() + .flex_none() + .w(HAIRLINE) + .h_full() + .bg(gpui::rgb(sf.base)), + ) + .child(self.scm_commit_menu(repo, cx)), + ) + .into_any_element() + } + + /// The chevron half of the split control. + /// + /// Never disabled, whatever the button beside it is doing: stash and amend + /// still mean something with nothing staged, and this is the only way to + /// reach them. + fn scm_commit_menu(&self, repo: &RepoKey, cx: &mut Context) -> AnyElement { + let amend = self.scm.amend; + Button::new("scm-commit-menu") + .custom(commit_half(cx)) + .icon(Icon::new(IconName::ChevronDown)) + // `Button` sizes an icon off its own `Size`, so the glyph is asked + // for by way of the size that produces it — the same conversion + // `chrome_tile_sized` does. + .with_size(px(COMMIT_GLYPH / crate::ui::tab_strip::BUTTON_ICON_SCALE)) + .w(px(COMMIT_CHEVRON_W)) + .h_full() + .rounded_corners(segment_corners(1, 2, CARD_RADIUS, HAIRLINE)) + .dropdown_menu_with_anchor(gpui::Anchor::TopRight, { + let app = cx.entity().downgrade(); + let repo = repo.clone(); + move |menu, _window, _cx| { + let mut menu = menu.min_w(px(190.)); + for (label, intent) in [ + (L10nKey::ScmCommitButton, ScmIntent::Commit), + (L10nKey::ScmCommitAndPush, ScmIntent::CommitAndPush), + (L10nKey::ScmCommitAndSync, ScmIntent::CommitAndSync), + ] { + menu = menu.item(PopupMenuItem::new(t(label)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app + .update(cx, |this, cx| this.run_scm_action(intent, window, cx)); + } + })); + } + menu = menu.separator().item( + // A menu item rather than a checkbox row: the panel is + // 260px wide, and the armed state already shows up in the + // button's label and in the chip on the branch row. + PopupMenuItem::new(t(L10nKey::ScmAmendLastCommit)) + .checked(amend) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm.amend = !this.scm.amend; + cx.notify(); + }); + } + }), + ); + menu.separator() + .item(PopupMenuItem::new(t(L10nKey::ScmStashAll)).on_click({ + let app = app.clone(); + let repo = repo.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm_stash_all(repo.clone(), window, cx) + }); + } + })) + } + }) + .into_any_element() + } + + /// Title over a scrolling body, with the panel's own scroll handle. + /// + /// Not `panel_scroll`: that one owns `right_panel.scroll`, and the rows + /// that land between the title and the list in later steps have to stay + /// pinned while the list moves under them. + fn scm_shell(&self, title: AnyElement, body: AnyElement) -> AnyElement { + self.scm_shell_with(title, Vec::new(), body) + } + + /// `pinned` rows sit between the title and the list and do not scroll: + /// the message box has to stay reachable however far down the files go. + fn scm_shell_with( + &self, + title: AnyElement, + pinned: Vec, + body: AnyElement, + ) -> AnyElement { + self.scm_shell_full(title, pinned, body, None) + } + + /// …and `footer` is the history section, which scrolls on its own. + /// + /// The four bands sit flush on the panel with nothing between them — no + /// gutter, no fill, no rounded blocks. What separates them is the same + /// thing that separates every other stack of rows in the right panel: the + /// rows' own insets and the pause between them. Each band keeps its own + /// `CONTENT_INSET`, so the panel's text column stays one column from the + /// title to the last commit in the history. + fn scm_shell_full( + &self, + title: AnyElement, + pinned: Vec, + body: AnyElement, + footer: Option, + ) -> AnyElement { + let scroller = div() + .id("panel-scm-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scm.scroll) + .child(body); + v_flex() + .flex_1() + .min_h_0() + .child(title) + .children(pinned) + .child(crate::ui::scrollbar::with_vertical_scrollbar( + "panel-scm-scrollbar", + scroller, + &self.scm.scroll, + )) + .children(footer) + .into_any_element() + } + + /// The host and directory the panel is looking at. + fn scm_pane_target( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(SharedHost, PathBuf)> { + let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; + let view = leaf.read(cx); + let cwd = view + .git_status_cwd() + .map(Path::to_path_buf) + .or_else(|| view.host_cwd())?; + Some((view.host(cx)?, cwd)) + } + + /// Turn the pane's directory into the repository root every write has to + /// run from. + /// + /// Pathspecs out of `status --porcelain=v2` are relative to the root, so + /// running `git add` from a subdirectory would name the wrong files. The + /// root is also the cache key, which is what lets two panes in two + /// subdirectories of one repository share a single status. + /// + /// Resolved with its own `rev-parse` rather than borrowed from the cheap + /// per-tab cache. That cache holds a repository's *home*, which is a + /// different directory inside a linked worktree, and it is only filled in + /// for panes whose shell reports a cwd — a pane without shell integration + /// would leave the panel loading forever. + fn scm_repo_root( + &mut self, + host: &SharedHost, + cwd: &Path, + cx: &mut Context, + ) -> RepoLookup { + let id = host.id(); + let key = (id, cwd.to_path_buf()); + match self.scm.roots.get(&key) { + Some((_, Some(root))) => return RepoLookup::Root(root.clone()), + // "Not a repository" is re-asked now and then, because `git init` + // in the pane below has to start showing up without a restart. + Some((at, None)) if at.elapsed() < NOT_A_REPO_RETRY => return RepoLookup::NotARepo, + _ => {} + } + if !self.scm.root_lookups.insert(key.clone()) { + return RepoLookup::Pending; + } + let dir = cwd.to_path_buf(); + crate::ui::host_ops::HostOps::run( + host.clone(), + cx, + move |h| { + // Asked separately from the status probe, and asked first: the + // answer is what everything else is keyed by, it is the same + // for every pane in the tree, and it is a ref lookup rather + // than a walk of the working tree. + tty7_core::core::git::git( + h, + &dir, + &["rev-parse", "--path-format=absolute", "--show-toplevel"], + ) + }, + move |this, out, cx| { + this.scm.root_lookups.remove(&key); + let root = out + .as_deref() + .and_then(|s| s.lines().next()) + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(PathBuf::from); + this.scm.roots.insert(key, (Instant::now(), root)); + cx.notify(); + }, + ); + RepoLookup::Pending + } + + /// `scm_refresh` with a floor under how often a fruitless probe repeats. + fn scm_probe(&mut self, host: &SharedHost, root: &Path, cx: &mut Context) { + let key = (host.id(), root.to_path_buf()); + if status_of(cx, host.id(), root).is_none() { + let now = Instant::now(); + match self.scm.probe_attempt.get(&key) { + Some(at) if now.duration_since(*at) < PROBE_RETRY => return, + _ => { + self.scm.probe_attempt.insert(key, now); + } + } + } + self.scm_refresh(host.clone(), root.to_path_buf(), cx); + } + + /// Read the status and record which one this frame drew, so the watcher + /// below can tell a real change from its own noise. + fn scm_seen_status( + &mut self, + host: HostId, + root: &Path, + cx: &mut Context, + ) -> Option> { + let status = status_of(cx, host, root); + self.scm.seen = Some(( + (host, root.to_path_buf()), + status.as_ref().map_or(0, |s| Arc::as_ptr(s) as usize), + )); + status + } + + /// Re-render when a probe lands. + /// + /// The subscription has to compare before it notifies. `scm_refresh` + /// reaches for `ScmData` through `default_global`, which fires the global + /// observers whether or not anything changed — and it is called from + /// `render`. An unconditional `cx.notify()` here would therefore ask for a + /// frame from inside a frame, forever. + fn scm_watch_status(&mut self, cx: &mut Context) { + if self.scm.watch.is_some() { + return; + } + self.scm.watch = Some(cx.observe_global::( + |this, cx| { + let Some((key, seen)) = this.scm.seen.clone() else { + return; + }; + let now = status_of(cx, key.0, &key.1).map_or(0, |s| Arc::as_ptr(&s) as usize); + if now != seen { + this.scm.seen = Some((key, now)); + cx.notify(); + } + }, + )); + } + + fn scm_groups( + &mut self, + repo: &RepoKey, + status: &Arc, + cx: &mut Context, + ) -> AnyElement { + let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.)); + for group in ScmGroup::ORDER { + let entries: Vec<&StatusEntry> = status + .entries + .iter() + .filter(|e| in_group(e, group)) + .collect(); + if entries.is_empty() { + continue; + } + let collapsed = self.scm.group_collapsed(group, entries.len()); + list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx)); + if collapsed { + continue; + } + let shown = entries.len().min(MAX_RENDERED_FILES); + for entry in entries.iter().take(shown) { + list = list.child(self.scm_file_row(repo, group, entry, cx)); + } + if entries.len() > shown { + list = list.child(self.scm_note( + t_plural(L10nKey::PanelMoreChangedFiles, entries.len() - shown, &[]), + cx, + )); + } + } + if status.truncated { + list = list.child(self.scm_note( + t_fmt( + L10nKey::ScmTooManyChanges, + &[ + ("shown", &status.entries.len().to_string()), + ("total", &status.total_entries.to_string()), + ], + ), + cx, + )); + } + list.into_any_element() + } + + /// "…and 40 more", and the note about a status git had to truncate. + /// + /// Secondary: it is prose about the list rather than a row of it, and the + /// one thing it must not do is read as another file. + fn scm_note(&self, text: String, cx: &mut Context) -> AnyElement { + div() + .px(px(ROW_INSET)) + .py(px(3.)) + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(text) + .into_any_element() + } + + fn scm_group_header( + &self, + repo: &RepoKey, + group: ScmGroup, + entries: &[&StatusEntry], + collapsed: bool, + cx: &mut Context, + ) -> AnyElement { + let count = entries.len(); + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + let id = SharedString::from(format!("scm-group-{group:?}")); + let actions = self.scm_group_actions(&id, repo, group, entries, sf.hover, cx); + h_flex() + .id(id.clone()) + .group(id) + .relative() + .items_center() + .gap(px(8.)) + .h(px(ROW_H)) + .px(px(ROW_INSET)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.scm_toggle_group(group, count, cx); + })) + // The chevron's box is exactly the width of `git_badge`, so this + // column and the status letters below it are one straight line. + .child( + div() + .flex_none() + .w(px(BADGE_W)) + .flex() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .size(px(11.)), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(10.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(t(group_label(group)).to_uppercase()), + ) + .child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(count.to_string()), + ) + .child(actions) + .into_any_element() + } + + fn scm_file_row( + &self, + repo: &RepoKey, + group: ScmGroup, + entry: &StatusEntry, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + let path = entry.path.as_str().to_string(); + let (name, dir) = split_display_path(&path); + let (letter, deco) = row_status(entry, group); + let source = group_diff_source(group); + let selected = + self.diff_overlay_focus(repo.host, &repo.root, &source) == Some(path.as_str()); + let id = SharedString::from(format!("scm-row-{group:?}-{path}")); + let actions = self.scm_row_actions( + &id, + repo, + group, + entry, + if selected { sf.selected } else { sf.hover }, + cx, + ); + + h_flex() + .id(id.clone()) + .group(id) + .relative() + .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 = repo.clone(); + let path = path.clone(); + cx.listener(move |this, _, window, cx| { + this.open_diff_overlay( + repo.host, + repo.root.clone(), + source.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + .context_menu({ + let app = cx.entity().downgrade(); + let repo = repo.clone(); + let entry = entry.clone(); + move |menu, _window, cx| { + Self::scm_row_context_menu(menu, &app, &repo, group, &entry, cx) + } + }) + .child(git_badge(letter, status_color(deco, cx), &mono)) + // Mono, because a path is a token you compare character by + // character. + // + // `scm/detail.rs` draws the changed-file rows of a commit and + // spells this number and the directory's out by hand so the two + // lists stay pixel-identical. Moving one means moving the other. + .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()), + ) + // The directory gives way first: which file it is matters more + // than where it lives, and the name is already the shorter half. + .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()), + ) + }) + .child(actions) + .into_any_element() + } + + /// The buttons that appear over a hovered row. + fn scm_row_actions( + &self, + row: &SharedString, + repo: &RepoKey, + group: ScmGroup, + entry: &StatusEntry, + backing: u32, + cx: &mut Context, + ) -> AnyElement { + // A path git cannot be given is a path nothing can be done to. The + // row stays readable and the buttons say why they are dead. + let writable = entry.path.pathspec().is_some(); + let path = entry.path.as_str(); + let mut actions = action_strip(row, backing); + + for &(verb, ref icon) in row_verbs(group) { + let id = SharedString::from(format!("scm-{verb:?}-{group:?}-{path}")); + let repo = repo.clone(); + let entry = entry.clone(); + actions = actions.child( + self.scm_tile(id, icon.clone(), verb_tooltip(verb), writable, cx) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.scm_row_verb(verb, &repo, group, &entry, window, cx); + })), + ); + } + actions.into_any_element() + } + + fn scm_group_actions( + &self, + row: &SharedString, + repo: &RepoKey, + group: ScmGroup, + entries: &[&StatusEntry], + backing: u32, + cx: &mut Context, + ) -> AnyElement { + let paths = writable_paths(entries); + let mut actions = action_strip(row, backing); + + for &(verb, ref icon) in group_verbs(group) { + let id = SharedString::from(format!("scm-all-{verb:?}-{group:?}")); + let repo = repo.clone(); + let paths = paths.clone(); + actions = actions.child( + self.scm_tile( + id, + icon.clone(), + verb_all_tooltip(verb), + !paths.is_empty(), + cx, + ) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + let Some(op) = verb_op(verb, group, paths.clone()) else { + return; + }; + this.scm_op(repo.clone(), op, window, cx); + })), + ); + } + actions.into_any_element() + } + + /// A [`TILE_SIZE_XS`] tile. Smaller than `TILE_SIZE_SM`, because three of + /// those on a row would eat 72 of the 236px a file name has to live in. + fn scm_tile( + &self, + id: SharedString, + icon: IconName, + tooltip: &'static str, + enabled: bool, + cx: &mut Context, + ) -> Button { + crate::ui::tab_strip::chrome_tile_sized( + Button::new(id).icon(Icon::new(icon.clone())), + TILE_SIZE_XS, + TILE_GLYPH_XS, + false, + cx, + ) + .rounded(px(4.)) + .disabled(!enabled) + .tooltip(if enabled { + tooltip + } else { + t(L10nKey::ScmUnrepresentablePath) + }) + } + + fn scm_row_verb( + &mut self, + verb: RowVerb, + repo: &RepoKey, + group: ScmGroup, + entry: &StatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + if verb == RowVerb::OpenConflict { + self.open_diff_overlay( + repo.host, + repo.root.clone(), + group_diff_source(group), + Some(entry.path.as_str().to_string()), + window, + cx, + ); + return; + } + let Some(op) = verb_op(verb, group, vec![entry.path.clone()]) else { + return; + }; + self.scm_op(repo.clone(), op, window, cx); + } + + fn scm_row_context_menu( + menu: PopupMenu, + app: &gpui::WeakEntity, + repo: &RepoKey, + group: ScmGroup, + entry: &StatusEntry, + cx: &gpui::App, + ) -> PopupMenu { + let danger = cx.theme().danger; + let rel = entry.path.as_str().to_string(); + let absolute = repo.root.join(&rel); + let source = group_diff_source(group); + let staged = group == ScmGroup::Staged; + + let mut menu = menu + .min_w(px(200.)) + .item( + PopupMenuItem::new(t(L10nKey::FileTreeContextOpen)).on_click({ + let app = app.clone(); + let absolute = absolute.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.open_file_in_editor(&absolute, window, cx) + }); + } + }), + ) + .item(PopupMenuItem::new(t(L10nKey::ScmOpenChanges)).on_click({ + let app = app.clone(); + let repo = repo.clone(); + let rel = rel.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.open_diff_overlay( + repo.host, + repo.root.clone(), + source.clone(), + Some(rel.clone()), + window, + cx, + ); + }); + } + })) + .separator() + .item( + PopupMenuItem::new(if staged { + t(L10nKey::ScmUnstage) + } else { + t(L10nKey::ScmStage) + }) + .on_click({ + let app = app.clone(); + let repo = repo.clone(); + let paths = vec![entry.path.clone()]; + move |_, window, cx| { + let op = if staged { + GitOp::Unstage { + paths: paths.clone(), + } + } else { + GitOp::Stage { + paths: paths.clone(), + } + }; + let _ = + app.update(cx, |this, cx| this.scm_op(repo.clone(), op, window, cx)); + } + }), + ) + .separator() + .item( + PopupMenuItem::new(t(L10nKey::FileTreeContextCopyPath)).on_click({ + let absolute = absolute.clone(); + move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + absolute.display().to_string(), + )); + } + }), + ); + + // Revealing a path only means anything on the machine the window is + // running on; a remote repository's paths are not this filesystem's. + if repo.host == HostId::LOCAL { + menu = menu.item( + PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({ + let absolute = absolute.clone(); + move |_, _window, cx| cx.reveal_path(&absolute) + }), + ); + } + + if let Some(op) = verb_op(RowVerb::Discard, group, vec![entry.path.clone()]) { + menu = menu.separator().item( + PopupMenuItem::element(move |_window, _cx| { + div().text_color(danger).child(t(L10nKey::ScmDiscard)) + }) + .on_click({ + let app = app.clone(); + let repo = repo.clone(); + move |_, window, cx| { + let op = op.clone(); + let _ = + app.update(cx, |this, cx| this.scm_op(repo.clone(), op, window, cx)); + } + }), + ); + } + menu + } + + /// What `app.rs`'s `GitStatusCache` observer calls when the cheap + /// per-tab probe lands. + /// + /// That probe runs at every command boundary, which makes it the best + /// signal there is that the working tree moved — far better than a timer. + /// The comparison in front of the bump is what keeps it from turning every + /// notification (including the one the probe's *start* fires) into another + /// `git status`. + pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { + let Some(repo) = self.scm.repo.clone() else { + return; + }; + let Some(seen) = cx + .try_global::() + .and_then(|cache| cache.status_for(repo.host, &repo.root)) + else { + return; + }; + if self.scm.last_tab_status.as_ref() == Some(&seen) { + return; + } + self.scm.last_tab_status = Some(seen); + self.scm_invalidate(repo.host, &repo.root, cx); + cx.notify(); + } + + fn scm_toggle_group(&mut self, group: ScmGroup, count: usize, cx: &mut Context) { + let collapsed = self.scm.group_collapsed(group, count); + self.scm.set_group_collapsed(group, !collapsed); + cx.notify(); + } +} + +/// The strip the row and group action buttons live in, revealed by hovering +/// `row`. +/// +/// Absolutely positioned and opaque, so it covers the tail of the directory +/// rather than pushing it aside: hovering a row must not move a single pixel +/// of it, or the list crawls under the pointer. +/// +/// It stops the mouse-down by hand instead of calling `occlude()`, which is +/// the obvious way to keep a click off the row underneath and was what made +/// the buttons vanish the moment the pointer reached them. `occlude()` is a +/// *hitbox* behaviour, and gpui inserts hitboxes in prepaint, which never +/// looks at `visibility` — so the strip blocked the mouse even while it was +/// invisible. Blocking cuts the hit test short at the blocking hitbox, and the +/// row's hitbox is behind this one because a parent prepaints before its +/// children; `group_hover` is nothing more than "is the group's hitbox +/// hovered", so the row stopped counting as hovered and the strip hid itself +/// — background, buttons and all — with the pointer sitting right on it. The +/// buttons' own hitboxes came from prepaint and outlived the paint, so they +/// went on answering tooltips for glyphs that were no longer drawn. +/// +/// Stopping propagation buys the same "this click is ours, not the row's" +/// without lying to the hit test: children register their handlers after this +/// one and gpui bubbles back to front, so a button still gets its click first. +fn action_strip(row: &SharedString, backing: u32) -> gpui::Div { + h_flex() + .absolute() + .right(px(ROW_INSET)) + .top_0() + .bottom_0() + .items_center() + .gap(px(1.)) + .bg(gpui::rgb(backing)) + .invisible() + .group_hover(row.clone(), |s| s.visible()) + .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) +} + +/// Which sections an entry shows up in. +/// +/// A file can be staged and unstaged at once (`XY == "MM"`), and then it +/// belongs in both — the same thing VS Code shows, and the honest reading of +/// what a commit right now would contain. +pub(crate) fn in_group(entry: &StatusEntry, group: ScmGroup) -> bool { + match group { + ScmGroup::Merge => entry.is_conflicted(), + ScmGroup::Untracked => entry.is_untracked(), + ScmGroup::Staged => entry.is_staged(), + ScmGroup::Changes => entry.is_unstaged() && !entry.is_untracked(), + } +} + +/// The letter and colour a row wears, decided by the half of `XY` its group +/// is about — a file staged as added and then modified is `A` under Staged +/// and `M` under Changes, which is what `git status` itself says. +pub(crate) fn row_status(entry: &StatusEntry, group: ScmGroup) -> (&'static str, DecoStatus) { + let deco = match group { + ScmGroup::Merge => DecoStatus::Conflict, + ScmGroup::Untracked => DecoStatus::Untracked, + ScmGroup::Staged => code_deco(entry.index), + ScmGroup::Changes => code_deco(entry.worktree), + }; + let letter = match group { + // The two groups whose letter is fixed use the shared glyph table, so + // a conflict is `U` here and in the file tree alike. + ScmGroup::Merge | ScmGroup::Untracked => status_glyph(deco), + ScmGroup::Staged => letter_of(entry.index), + ScmGroup::Changes => letter_of(entry.worktree), + }; + (letter, deco) +} + +/// `ChangeCode::letter` returns a `char`; rows want a `&'static str` so the +/// badge never allocates. `T` and `C` keep their own letters rather than being +/// folded into `M` and `R` — git shows them, and they mean different things. +fn letter_of(code: ChangeCode) -> &'static str { + match code { + ChangeCode::None => " ", + ChangeCode::Modified => "M", + ChangeCode::TypeChanged => "T", + ChangeCode::Added => "A", + ChangeCode::Deleted => "D", + ChangeCode::Renamed => "R", + ChangeCode::Copied => "C", + ChangeCode::Unmerged => "U", + } +} + +fn code_deco(code: ChangeCode) -> DecoStatus { + match code { + ChangeCode::Deleted => DecoStatus::Deleted, + ChangeCode::Added => DecoStatus::Added, + ChangeCode::Renamed | ChangeCode::Copied => DecoStatus::Renamed, + ChangeCode::Unmerged => DecoStatus::Conflict, + _ => DecoStatus::Modified, + } +} + +/// Which patch a row's click opens. +/// +/// Staged rows show `git diff --cached`; everything else shows the working +/// tree. Getting this wrong is not cosmetic — the file name would be right and +/// the hunks underneath it would be someone else's. +pub(crate) fn group_diff_source(group: ScmGroup) -> DiffSource { + match group { + ScmGroup::Staged => DiffSource::Staged, + ScmGroup::Merge | ScmGroup::Changes | ScmGroup::Untracked => DiffSource::Worktree, + } +} + +fn group_label(group: ScmGroup) -> L10nKey { + match group { + ScmGroup::Merge => L10nKey::ScmGroupMerge, + ScmGroup::Staged => L10nKey::ScmGroupStaged, + ScmGroup::Changes => L10nKey::ScmGroupChanges, + ScmGroup::Untracked => L10nKey::ScmGroupUntracked, + } +} + +/// Whether a group nobody has touched starts folded. +pub(crate) fn starts_collapsed(group: ScmGroup, count: usize) -> bool { + group == ScmGroup::Untracked && count > UNTRACKED_AUTO_COLLAPSE +} + +/// What the branch row says where the branch name goes. +pub(crate) fn head_label(head: &HeadState) -> String { + match head { + // A detached HEAD has no name, so it wears its sha — shortened to the + // seven characters git itself abbreviates to. + HeadState::Detached { oid } => oid.chars().take(7).collect(), + _ => head.label(), + } +} + +/// The `↑2 ↓0` reading, or nothing. +/// +/// A branch that is level with its upstream says nothing at all: the quiet +/// state is the common one, and a token that is always there stops being read. +/// A branch with no upstream offers to publish instead. +/// +/// Once there *is* something to say, both halves are said, zero included. The +/// pair is one reading of one distance, and dropping the empty half makes it +/// change shape as the numbers move — `↑2` becoming `↑2 ↓1` on a fetch is a +/// different-looking thing in the corner of the eye, where `↑2 ↓0` becoming +/// `↑2 ↓1` is the same thing with a digit changed. +pub(crate) fn tracking_chip( + upstream: Option<&str>, + ahead_behind: Option<(u32, u32)>, +) -> Option { + if upstream.is_none() { + return Some(t(L10nKey::ScmPublishBranch).to_string()); + } + match ahead_behind? { + (0, 0) => None, + (ahead, behind) => Some(format!("↑{ahead} ↓{behind}")), + } +} + +/// A quiet token on the branch row: the tracking distance, or the count of +/// other repositories. +/// +/// Not `info_chip`. A chip's fill is what earns it the right to shout, and the +/// only two things on this row worth shouting are a state you could forget you +/// are in — detached, mid-rebase, armed to amend — and they already wear the +/// warning tint. `↑2 ↓0` is a reading, so it reads as text; the row's own 6px +/// gap is enough to keep it off the branch name without a box around it. +/// +/// The same quiet mono step the panel's badges and chips are set on, minus the +/// fill, so a note and a chip still line up as one row of tokens. +/// The fill under the commit area's two shapes — the message box and the split +/// button's frame. +/// +/// Half of the ramp's first rung, not the rung itself. `hover` is what a row +/// wears while the pointer is on it, which is a state that lasts a moment; +/// these two wear their fill permanently, and at full strength they were the +/// loudest thing on an idle panel. Both read from here so they cannot drift +/// apart: the commit area is one block, and two greys a shade off each other +/// look like a mistake rather than a hierarchy. +fn field_fill(sf: crate::ui::presets::Surface) -> u32 { + crate::ui::presets::mix(sf.base, sf.hover, 0.5) +} + +/// One half of the split commit control: paints nothing, ever. +/// +/// Every state is transparent so the frame around both halves is the only +/// thing that fills, and the control lights as one shape rather than one end. +/// Two of those states are worth naming, because a stock variant gets them +/// wrong here in opposite directions: +/// +/// * `.ghost()` hovers to `sidebar_accent`, which sits close enough to the +/// frame's own fill that the half would light almost invisibly — the worst of +/// both, a highlight you can see is uneven but cannot read. +/// * gpui-component resolves a `Custom` variant's *selected* paint from its +/// `active` slot, and a dropdown marks its trigger selected for as long as +/// the menu is open. Anything but transparent there parks a block on the +/// chevron for the whole time the user is reading the menu. +fn commit_half(cx: &gpui::App) -> ButtonCustomVariant { + let clear = gpui::transparent_black(); + ButtonCustomVariant::new(cx) + .color(clear) + .foreground(cx.theme().foreground) + .hover(clear) + .active(clear) +} + +fn branch_note(text: &str, ink: gpui::Hsla, mono: &SharedString) -> AnyElement { + div() + .flex_none() + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(ink) + .child(text.to_string()) + .into_any_element() +} + +/// Which sequencer operation is parked in the repository. +/// +/// `RebaseInteractive` reads as "rebasing" on purpose: modern git writes +/// `rebase-merge/interactive` for every rebase, so the distinction the variant +/// name suggests is not one the repository on disk can actually make — and +/// `git status` does not draw it either. +pub(crate) fn operation_label(op: RepoOperation) -> L10nKey { + match op { + RepoOperation::Merge => L10nKey::ScmOpMerge, + RepoOperation::Rebase | RepoOperation::RebaseInteractive => L10nKey::ScmOpRebase, + RepoOperation::CherryPick => L10nKey::ScmOpCherryPick, + RepoOperation::Revert => L10nKey::ScmOpRevert, + RepoOperation::Bisect => L10nKey::ScmOpBisect, + RepoOperation::Am => L10nKey::ScmOpAm, + } +} + +/// What the commit button says, and whether it can be pressed at all. +pub(crate) struct CommitPlan { + pub(crate) label: L10nKey, + pub(crate) enabled: bool, + /// Why the button is disabled, when it is. "Nothing to commit" and "write + /// a message" call for opposite actions, and one tooltip for both sends + /// the user staging files they already staged. + pub(crate) reason: L10nKey, +} + +/// Decide both from the state of the index. +/// +/// "Commit All" rather than a silent `-a`: with nothing staged, `git commit` +/// would commit nothing, and the honest thing is to say on the button that +/// every tracked change is about to go in. An armed amend needs no message — +/// `--no-edit` keeps the one that is already on HEAD. +pub(crate) fn commit_plan(status: &WorkingTreeStatus, amend: bool, message: &str) -> CommitPlan { + let staged = status.staged().next().is_some(); + let tracked_edits = status.unstaged().next().is_some(); + let label = if amend { + L10nKey::ScmCommitAmendButton + } else if staged { + L10nKey::ScmCommitButton + } else { + L10nKey::ScmCommitAllButton + }; + let has_message = !message.trim().is_empty(); + // Mid-merge, an empty message is still committable: `ops` sends + // `--allow-empty-message` for exactly this, because refusing would strand + // the merge behind a message the user deliberately cleared. + let merging = matches!(status.operation, Some(RepoOperation::Merge)); + let something = staged || tracked_edits || amend; + CommitPlan { + label, + enabled: something && (has_message || amend || merging), + reason: if something { + L10nKey::ScmCommitNeedsMessage + } else { + L10nKey::ScmNothingToCommit + }, + } +} + +/// Whether a commit has to stage everything tracked first (`-a`). +pub(crate) fn commit_stages_everything(status: &WorkingTreeStatus, amend: bool) -> bool { + // Amending with nothing staged means "fix the message", not "sweep the + // working tree into the commit I already made". + !amend && status.staged().next().is_none() +} + +/// What a row's buttons do. Named rather than inlined because the same verb +/// appears on the row, on its group header and in its context menu, and the +/// three must not drift into meaning different things. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum RowVerb { + Discard, + Stage, + Unstage, + OpenConflict, + MarkResolved, +} + +/// Right to left, most-used last: the pointer travels to the right edge, so +/// the button under it should be the one nine hovers out of ten want. +pub(crate) fn row_verbs(group: ScmGroup) -> &'static [(RowVerb, IconName)] { + match group { + ScmGroup::Merge => &[ + (RowVerb::OpenConflict, IconName::Eye), + (RowVerb::MarkResolved, IconName::Check), + ], + ScmGroup::Staged => &[(RowVerb::Unstage, IconName::Minus)], + ScmGroup::Changes | ScmGroup::Untracked => &[ + (RowVerb::Discard, IconName::Undo2), + (RowVerb::Stage, IconName::Plus), + ], + } +} + +/// The header's buttons are the row's, minus the ones that only make sense +/// for one file: there is no group-wide "open the conflict". +pub(crate) fn group_verbs(group: ScmGroup) -> &'static [(RowVerb, IconName)] { + match group { + ScmGroup::Merge => &[(RowVerb::MarkResolved, IconName::Check)], + _ => row_verbs(group), + } +} + +/// The operation a verb runs over one or many paths. `None` for the verbs +/// that change no state. +pub(crate) fn verb_op(verb: RowVerb, group: ScmGroup, paths: Vec) -> Option { + if paths.is_empty() { + return None; + } + Some(match verb { + // Resolving a conflict is `git add`, exactly as it is on the command + // line — there is no separate "resolve" verb in git. + RowVerb::Stage | RowVerb::MarkResolved => GitOp::Stage { paths }, + RowVerb::Unstage => GitOp::Unstage { paths }, + RowVerb::Discard if group == ScmGroup::Untracked => { + let directories = paths.iter().any(|p| p.as_str().ends_with('/')); + GitOp::DiscardUntracked { paths, directories } + } + RowVerb::Discard => GitOp::DiscardWorktree { paths }, + RowVerb::OpenConflict => return None, + }) +} + +/// The paths in a group git can actually be told about. +/// +/// A path that is not valid UTF-8 cannot be sent as a pathspec at all, and +/// `GitOp::validate` rejects the whole operation over one of them — so a group +/// action has to leave them out rather than fail for everyone. Their own rows +/// are greyed out and say why. +pub(crate) fn writable_paths(entries: &[&StatusEntry]) -> Vec { + entries + .iter() + .filter(|e| e.path.pathspec().is_some()) + .map(|e| e.path.clone()) + .collect() +} + +fn verb_tooltip(verb: RowVerb) -> &'static str { + t(match verb { + RowVerb::Discard => L10nKey::ScmDiscard, + RowVerb::Stage => L10nKey::ScmStage, + RowVerb::Unstage => L10nKey::ScmUnstage, + RowVerb::OpenConflict => L10nKey::ScmOpenConflict, + RowVerb::MarkResolved => L10nKey::ScmMarkResolved, + }) +} + +fn verb_all_tooltip(verb: RowVerb) -> &'static str { + t(match verb { + RowVerb::Discard => L10nKey::ScmDiscardAll, + RowVerb::Stage => L10nKey::ScmStageAll, + RowVerb::Unstage => L10nKey::ScmUnstageAll, + RowVerb::OpenConflict => L10nKey::ScmOpenConflict, + RowVerb::MarkResolved => L10nKey::ScmMarkResolved, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::actions::{ScmCommit, ToggleFullscreen}; + use crate::core::config::{CoreConfig, DiffViewMode, RightPanelTab}; + use crate::ui::app::test_window::harness; + use gpui::TestAppContext; + use tty7_core::core::git::status::{ConflictKind, EntryKind, RepoPath}; + + fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry { + StatusEntry { + path: RepoPath::from_bytes(path.as_bytes()), + orig_path: None, + index, + worktree, + kind, + submodule: None, + rename_score: None, + conflict: (kind == EntryKind::Unmerged).then_some(ConflictKind::BothModified), + } + } + + fn groups_of(entry: &StatusEntry) -> Vec { + ScmGroup::ORDER + .into_iter() + .filter(|g| in_group(entry, *g)) + .collect() + } + + #[test] + fn a_file_staged_and_edited_again_lands_in_both_groups() { + let e = entry( + "a.rs", + ChangeCode::Modified, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&e), vec![ScmGroup::Staged, ScmGroup::Changes]); + } + + #[test] + fn each_other_kind_of_entry_lands_in_exactly_one_group() { + let staged = entry( + "a.rs", + ChangeCode::Added, + ChangeCode::None, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&staged), vec![ScmGroup::Staged]); + + let unstaged = entry( + "b.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(groups_of(&unstaged), vec![ScmGroup::Changes]); + + let untracked = entry( + "c.rs", + ChangeCode::None, + ChangeCode::None, + EntryKind::Untracked, + ); + assert_eq!(groups_of(&untracked), vec![ScmGroup::Untracked]); + + // A conflict is only ever a conflict: it must not also show up under + // Changes, or resolving it would look like two separate jobs. + let conflict = entry( + "d.rs", + ChangeCode::Unmerged, + ChangeCode::Unmerged, + EntryKind::Unmerged, + ); + assert_eq!(groups_of(&conflict), vec![ScmGroup::Merge]); + } + + #[test] + fn a_row_wears_the_letter_of_the_half_its_group_is_about() { + // Added to the index, then edited again in the working tree. + let e = entry( + "a.rs", + ChangeCode::Added, + ChangeCode::Modified, + EntryKind::Tracked, + ); + assert_eq!(row_status(&e, ScmGroup::Staged), ("A", DecoStatus::Added)); + assert_eq!( + row_status(&e, ScmGroup::Changes), + ("M", DecoStatus::Modified) + ); + + let untracked = entry( + "c.rs", + ChangeCode::None, + ChangeCode::None, + EntryKind::Untracked, + ); + assert_eq!( + row_status(&untracked, ScmGroup::Untracked), + ("?", DecoStatus::Untracked) + ); + let conflict = entry( + "d.rs", + ChangeCode::Unmerged, + ChangeCode::Unmerged, + EntryKind::Unmerged, + ); + assert_eq!( + row_status(&conflict, ScmGroup::Merge), + ("U", DecoStatus::Conflict) + ); + } + + #[test] + fn staged_rows_open_the_cached_diff_and_the_rest_the_working_tree() { + assert_eq!(group_diff_source(ScmGroup::Staged), DiffSource::Staged); + for group in [ScmGroup::Merge, ScmGroup::Changes, ScmGroup::Untracked] { + assert_eq!(group_diff_source(group), DiffSource::Worktree); + } + } + + #[test] + fn only_a_long_untracked_list_starts_folded() { + assert!(!starts_collapsed( + ScmGroup::Untracked, + UNTRACKED_AUTO_COLLAPSE + )); + assert!(starts_collapsed( + ScmGroup::Untracked, + UNTRACKED_AUTO_COLLAPSE + 1 + )); + for group in [ScmGroup::Merge, ScmGroup::Staged, ScmGroup::Changes] { + assert!(!starts_collapsed(group, 1_000)); + } + } + + #[test] + fn every_button_a_row_offers_maps_to_the_verb_it_is_named_for() { + let file = vec![RepoPath::from_bytes(b"a.rs")]; + assert!(matches!( + verb_op(RowVerb::Stage, ScmGroup::Changes, file.clone()), + Some(GitOp::Stage { .. }) + )); + assert!(matches!( + verb_op(RowVerb::Unstage, ScmGroup::Staged, file.clone()), + Some(GitOp::Unstage { .. }) + )); + // Resolving a conflict is `git add`; git has no other verb for it. + assert!(matches!( + verb_op(RowVerb::MarkResolved, ScmGroup::Merge, file.clone()), + Some(GitOp::Stage { .. }) + )); + assert!(matches!( + verb_op(RowVerb::Discard, ScmGroup::Changes, file.clone()), + Some(GitOp::DiscardWorktree { .. }) + )); + // `checkout --` cannot restore a file git has never heard of. + assert!(matches!( + verb_op(RowVerb::Discard, ScmGroup::Untracked, file.clone()), + Some(GitOp::DiscardUntracked { + directories: false, + .. + }) + )); + assert!(matches!( + verb_op( + RowVerb::Discard, + ScmGroup::Untracked, + vec![RepoPath::from_bytes(b"vendor/")] + ), + Some(GitOp::DiscardUntracked { + directories: true, + .. + }) + )); + assert!(verb_op(RowVerb::OpenConflict, ScmGroup::Merge, file).is_none()); + assert!(verb_op(RowVerb::Stage, ScmGroup::Changes, Vec::new()).is_none()); + } + + #[test] + fn everything_that_can_lose_work_says_so_before_it_runs() { + // The gate in `scm_op` keys off `destructive()`. If one of these ever + // stopped reporting, the panel would throw the work away in silence. + let paths = vec![RepoPath::from_bytes(b"a.rs")]; + for group in [ScmGroup::Changes, ScmGroup::Untracked] { + let op = verb_op(RowVerb::Discard, group, paths.clone()).expect("discard has an op"); + assert!(op.destructive().is_some(), "{group:?} discard"); + } + // Staging and unstaging are reversible, so they must not stop to ask. + for verb in [RowVerb::Stage, RowVerb::Unstage, RowVerb::MarkResolved] { + let op = verb_op(verb, ScmGroup::Changes, paths.clone()).expect("verb has an op"); + assert!(op.destructive().is_none(), "{verb:?}"); + } + } + + #[test] + fn a_group_action_leaves_out_the_paths_git_cannot_be_told_about() { + let good = entry( + "a.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + ); + let mut bad = good.clone(); + bad.path = RepoPath::from_bytes(&[0xff, 0xfe, b'.', b'r', b's']); + assert!(bad.path.pathspec().is_none(), "the fixture must be lossy"); + + let paths = writable_paths(&[&good, &bad]); + assert_eq!(paths.len(), 1, "the lossy path is dropped, not carried"); + // One unrepresentable path would otherwise fail the operation for the + // whole group. + let op = verb_op(RowVerb::Stage, ScmGroup::Changes, paths).expect("still has work to do"); + assert!(op.validate().is_ok()); + + let all_bad = verb_op(RowVerb::Stage, ScmGroup::Changes, writable_paths(&[&bad])); + assert!(all_bad.is_none(), "nothing to do means no operation at all"); + } + + #[test] + fn a_group_header_offers_no_button_that_only_makes_sense_for_one_file() { + let verbs: Vec = group_verbs(ScmGroup::Merge) + .iter() + .map(|(v, _)| *v) + .collect(); + assert_eq!(verbs, vec![RowVerb::MarkResolved]); + for group in [ScmGroup::Staged, ScmGroup::Changes, ScmGroup::Untracked] { + for (verb, _) in group_verbs(group) { + assert_ne!(*verb, RowVerb::OpenConflict); + } + } + } + + /// The group chevron's box and the status letter's cell are one column. + /// + /// This is the detail that makes the list look drawn rather than + /// assembled: every group arrow sits directly above the `M`s and `A`s of + /// the rows it heads. The two widths live in two files — `git_badge` owns + /// the letter's cell, this module owns the chevron's box — so nothing but + /// this assertion stops one of them from moving on its own. They have to + /// keep moving together. + #[test] + fn the_group_chevron_stands_in_the_status_letters_column() { + assert_eq!(BADGE_W, crate::ui::right_panel::BADGE_W); + } + + fn repo(root: &str) -> RepoKey { + RepoKey { + host: HostId::LOCAL, + root: PathBuf::from(root), + } + } + + fn status_of_repo(root: &str, entries: Vec) -> WorkingTreeStatus { + WorkingTreeStatus { + root: PathBuf::from(root), + home: PathBuf::from(root), + head: HeadState::Branch { + name: "main".into(), + oid: "1111111".into(), + }, + upstream: None, + ahead_behind: None, + total_entries: entries.len(), + entries, + truncated: false, + stash_count: 0, + operation: None, + prefilled_message: None, + } + } + + #[test] + fn the_commit_button_says_what_pressing_it_would_actually_do() { + let staged = status_of_repo( + "/a", + vec![entry( + "a.rs", + ChangeCode::Modified, + ChangeCode::None, + EntryKind::Tracked, + )], + ); + let unstaged = status_of_repo( + "/a", + vec![entry( + "a.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + )], + ); + let clean = status_of_repo("/a", Vec::new()); + + assert_eq!( + commit_plan(&staged, false, "msg").label, + L10nKey::ScmCommitButton + ); + // Nothing staged: the button says so rather than quietly running -a. + assert_eq!( + commit_plan(&unstaged, false, "msg").label, + L10nKey::ScmCommitAllButton + ); + assert_eq!( + commit_plan(&staged, true, "").label, + L10nKey::ScmCommitAmendButton + ); + + assert!(commit_plan(&staged, false, "msg").enabled); + assert!( + !commit_plan(&staged, false, " ").enabled, + "an all-whitespace message is no message" + ); + assert!( + commit_plan(&clean, true, "").enabled, + "amending with no message keeps HEAD's own with --no-edit" + ); + assert!(!commit_plan(&clean, false, "msg").enabled); + } + + #[test] + fn only_a_commit_with_an_empty_index_sweeps_the_working_tree() { + let staged = status_of_repo( + "/a", + vec![entry( + "a.rs", + ChangeCode::Modified, + ChangeCode::None, + EntryKind::Tracked, + )], + ); + let unstaged = status_of_repo( + "/a", + vec![entry( + "a.rs", + ChangeCode::None, + ChangeCode::Modified, + EntryKind::Tracked, + )], + ); + assert!(commit_stages_everything(&unstaged, false)); + assert!(!commit_stages_everything(&staged, false)); + // Amending is "fix the last commit", not "add everything to it". + assert!(!commit_stages_everything(&unstaged, true)); + } + + #[test] + fn a_branch_level_with_its_upstream_says_nothing() { + assert_eq!(tracking_chip(Some("origin/main"), Some((0, 0))), None); + // Both halves once there is anything to say, so the reading keeps its + // shape as the numbers move. + assert_eq!( + tracking_chip(Some("origin/main"), Some((2, 0))).as_deref(), + Some("↑2 ↓0") + ); + assert_eq!( + tracking_chip(Some("origin/main"), Some((0, 1))).as_deref(), + Some("↑0 ↓1") + ); + assert_eq!( + tracking_chip(Some("origin/main"), Some((2, 1))).as_deref(), + Some("↑2 ↓1") + ); + // Nothing to compare against is not the same as being level: the chip + // becomes the offer to publish. + assert_eq!( + tracking_chip(None, None).as_deref(), + Some(t(L10nKey::ScmPublishBranch)) + ); + // An upstream git could not count against says nothing rather than + // claiming zero. + assert_eq!(tracking_chip(Some("origin/main"), None), None); + } + + #[test] + fn a_detached_head_shows_the_sha_git_would_print() { + assert_eq!( + head_label(&HeadState::Detached { + oid: "0123456789abcdef".into() + }), + "0123456" + ); + assert_eq!( + head_label(&HeadState::Branch { + name: "main".into(), + oid: "0123456".into() + }), + "main" + ); + } + + #[test] + fn every_parked_operation_has_something_to_say() { + // Interactive and plain rebase read the same on purpose: git writes + // `rebase-merge/interactive` for both, so the distinction is not one + // the repository on disk can make. + assert_eq!( + operation_label(RepoOperation::RebaseInteractive), + operation_label(RepoOperation::Rebase) + ); + for op in [ + RepoOperation::Merge, + RepoOperation::Rebase, + RepoOperation::CherryPick, + RepoOperation::Revert, + RepoOperation::Bisect, + RepoOperation::Am, + ] { + assert!(!t(operation_label(op)).is_empty(), "{op:?}"); + } + } + + #[gpui::test] + fn commit_action_is_scoped_to_the_message_box(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (_app, mut vcx) = harness(cx); + + let (in_box, outside, window_wide) = vcx.update(|window, _cx| { + let scoped = gpui::KeyContext::parse(COMMIT_KEY_CONTEXT) + .expect("the context the panel installs parses"); + ( + window.bindings_for_action_in_context(&ScmCommit, scoped), + window.bindings_for_action_in_context( + &ScmCommit, + gpui::KeyContext::new_with_defaults(), + ), + window.bindings_for_action_in_context( + &ToggleFullscreen, + gpui::KeyContext::new_with_defaults(), + ), + ) + }); + + assert!( + !in_box.is_empty(), + "the message box installs {COMMIT_KEY_CONTEXT}, and the binding has to live in it" + ); + assert!( + outside.is_empty(), + "outside the box the chord must not commit anything" + ); + assert!( + !window_wide.is_empty(), + "the window keeps its own binding for the same chord" + ); + if cfg!(target_os = "macos") { + // The whole point: one chord, two meanings, separated only by the + // context the box installs. If they ever stopped colliding this + // test would still pass for the wrong reason, so assert they do. + assert_eq!( + in_box[0].keystrokes(), + window_wide[0].keystrokes(), + "secondary-enter is shared, and scope is what tells them apart" + ); + } + } + + #[gpui::test] + fn a_commit_draft_follows_its_repository(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + let (a, b) = (repo("/a"), repo("/b")); + let status_a = status_of_repo("/a", Vec::new()); + let status_b = status_of_repo("/b", Vec::new()); + + app.update_in(&mut vcx, |app, window, cx| { + let input = app.scm_commit_input(&a, &status_a, window, cx); + input.update(cx, |state, cx| state.set_value("wip: a", window, cx)); + + let input = app.scm_commit_input(&b, &status_b, window, cx); + assert_eq!( + input.read(cx).value(), + "", + "another working tree is another message" + ); + input.update(cx, |state, cx| state.set_value("wip: b", window, cx)); + + let input = app.scm_commit_input(&a, &status_a, window, cx); + assert_eq!( + input.read(cx).value(), + "wip: a", + "coming back has to bring the draft with it" + ); + assert_eq!(app.scm.draft(&b), "wip: b"); + }); + } + + #[gpui::test] + fn a_merge_prefills_the_box_with_gits_own_message(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + let key = repo("/a"); + let mut status = status_of_repo("/a", Vec::new()); + status.prefilled_message = Some("Merge branch 'topic'".into()); + + app.update_in(&mut vcx, |app, window, cx| { + let input = app.scm_commit_input(&key, &status, window, cx); + assert_eq!(input.read(cx).value(), "Merge branch 'topic'"); + }); + } + + #[gpui::test] + fn a_rejected_commit_keeps_the_message_it_was_given(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + let key = repo("/a"); + let before = status_of_repo("/a", Vec::new()); + + app.update(&mut vcx, |app, _cx| { + app.scm.committing = Some((key.clone(), before.head.clone(), "wip".into())); + + // A pre-commit hook said no: HEAD has not moved, so the message + // the user wrote is still the only copy of it there is. + assert!(!app.scm_commit_landed(&key, &before, "wip")); + + let mut after = before.clone(); + after.head = HeadState::Branch { + name: "main".into(), + oid: "2222222".into(), + }; + assert!(app.scm_commit_landed(&key, &after, "wip")); + assert_eq!(app.scm.draft(&key), ""); + }); + } + + fn tab_from(json: &str) -> RightPanelTab { + serde_json::from_str::(json) + .expect("config deserializes") + .right_panel_tab + } + + #[gpui::test] + fn scm_tab_opens_and_config_round_trips(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + app.update(&mut vcx, |app, cx| { + app.set_right_panel_tab(RightPanelTab::Scm, cx) + }); + let (visible, tab) = app.read_with(&vcx, |app, _| { + (app.right_panel_visible, app.right_panel_tab) + }); + assert!(visible); + assert_eq!(tab, RightPanelTab::Scm); + + // The whole reason the variant was renamed in place: what lands on + // disk is still `"changes"`, so a build from before this change reads + // its own config back and stays on the panel the user left open. + let cfg = CoreConfig { + right_panel_tab: RightPanelTab::Scm, + ..Default::default() + }; + let json = serde_json::to_value(&cfg).expect("config serializes"); + assert_eq!(json["right_panel_tab"], serde_json::json!("changes")); + + assert_eq!( + tab_from(r#"{"right_panel_tab":"changes"}"#), + RightPanelTab::Scm + ); + assert_eq!(tab_from(r#"{"right_panel_tab":"scm"}"#), RightPanelTab::Scm); + assert_eq!(tab_from(r#"{"right_panel_tab":"git"}"#), RightPanelTab::Scm); + // Anything unrecognised falls back through `de_lenient` rather than + // failing the whole file. + assert_eq!( + tab_from(r#"{"right_panel_tab":"nonsense"}"#), + RightPanelTab::Info + ); + } + + #[gpui::test] + fn the_new_config_fields_default_to_todays_behaviour(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + let cfg = CoreConfig::default(); + assert_eq!(cfg.diff_view, DiffViewMode::Split); + assert!(!cfg.scm_graph_expanded); + + // Toggling has to survive the round trip through the config, since the + // panel reads it back on the next launch. + app.update(&mut vcx, |app, cx| app.scm_toggle_graph(cx)); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.expanded)); + assert!(vcx.update(|_, cx| { + cx.global::() + .scm_graph_expanded + })); + + app.update(&mut vcx, |app, cx| app.toggle_diff_view_mode(cx)); + assert_eq!( + vcx.update(|_, cx| cx.global::().diff_view), + DiffViewMode::Unified + ); + } + + #[gpui::test] + fn a_folded_group_stays_folded_across_rerenders(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + app.update(&mut vcx, |app, cx| { + app.set_right_panel_tab(RightPanelTab::Scm, cx); + app.scm_toggle_group(ScmGroup::Staged, 3, cx); + }); + vcx.background_executor.run_until_parked(); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.scm.group_collapsed(ScmGroup::Staged, 3))); + + // And a long untracked list that the user opened by hand stays open, + // rather than snapping shut again on the count. + app.update(&mut vcx, |app, cx| { + app.scm_toggle_group(ScmGroup::Untracked, 500, cx) + }); + vcx.run_until_parked(); + assert!(!app.read_with(&vcx, |app, _| { + app.scm.group_collapsed(ScmGroup::Untracked, 500) + })); + } +} + +/// The panel asks git for a lot, from inside `render`. These hold it to +/// asking once and then going quiet. +/// +/// The hazard is specific: `scm_refresh` reaches for its cache through +/// `default_global`, which fires the global observers whether or not anything +/// changed, and it is called every frame. A watcher that notified on every one +/// of those would request a frame from inside a frame and never stop. +#[cfg(all(test, unix))] +mod render_idle_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 tty7_core::core::config::RightPanelTab; + + const BUDGET: u64 = 200; + + fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-scm-{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]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?} failed"); + } + + fn scm_panel_on( + cx: &mut TestAppContext, + root: &Path, + until: impl Fn(&Tty7App, &gpui::App) -> bool, + ) -> ( + 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 deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + if app.update_in(&mut vcx, |app, _, cx| until(app, cx)) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the panel never settled on the directory" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + vcx.background_executor.run_until_parked(); + (app, vcx, pane) + } + + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { + render_probe::arm(BUDGET); + 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(BUDGET); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(9)); + vcx.background_executor.run_until_parked(); + render_probe::draws() + } + + #[gpui::test] + fn a_settled_source_control_panel_reaches_render_idle(cx: &mut TestAppContext) { + let _serial = serial(); + let root = scratch("settled"); + git(&root, &["init", "--quiet"]); + std::fs::write(root.join("a.rs"), "fn main() {}\n").unwrap(); + git(&root, &["add", "a.rs"]); + std::fs::write(root.join("b.rs"), "// untracked\n").unwrap(); + + let want = root.clone(); + let (app, mut vcx, _pane) = scm_panel_on(cx, &root, move |app, cx| { + app.scm.repo.as_ref().is_some_and(|r| r.root == want) + && crate::terminal::git_data::status_of(cx, HostId::LOCAL, &want).is_some() + }); + + let status = app.update_in(&mut vcx, |app, _, cx| { + crate::terminal::git_data::status_of( + cx, + HostId::LOCAL, + &app.scm.repo.clone().unwrap().root, + ) + }); + let status = status.expect("the panel read a status"); + assert_eq!(status.staged().count(), 1, "a.rs is in the index"); + assert_eq!(status.untracked().count(), 1, "b.rs is not"); + + assert_eq!(draws_while_idle(&mut vcx), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[gpui::test] + fn a_directory_with_no_repository_reaches_render_idle(cx: &mut TestAppContext) { + let _serial = serial(); + let root = scratch("bare"); + std::fs::write(root.join("notes.txt"), "").unwrap(); + + // Nothing here is a repository, so `git status` must never be reached + // — and the panel must not spin looking for one that is not coming. + let want = root.clone(); + let (app, mut vcx, _pane) = scm_panel_on(cx, &root, move |app, _cx| { + app.scm.roots.contains_key(&(HostId::LOCAL, want.clone())) + }); + assert!( + app.update_in(&mut vcx, |app, _, _| app + .scm + .roots + .values() + .all(|(_, r)| r.is_none())), + "no root was resolved for a directory that is not a repository" + ); + assert!( + app.update_in(&mut vcx, |app, _, _| app.scm.repo.is_none()), + "and no status was ever asked for" + ); + assert_eq!(draws_while_idle(&mut vcx), 0); + let _ = std::fs::remove_dir_all(&root); + } +} + +/// The action strip is the one place in the panel where a hover reveal sits +/// under the pointer it is reacting to, so it is the one place where "is the +/// row hovered" and "can the row's hitbox be seen from here" can disagree. +#[cfg(test)] +mod action_strip_gpui_tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + use gpui::{ + Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + PlatformInput, Point, Render, TestAppContext, VisualTestContext, point, + }; + + /// A file row in miniature: a group, something to hover on the left, the + /// real strip on the right with one button-sized child in it, and the + /// row's own click handler — the one that opens the diff overlay, and the + /// one a click on the strip must never reach. + #[derive(Default)] + struct Row { + row_clicks: Rc>, + } + + impl Render for Row { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let id = SharedString::from("row"); + let clicks = self.row_clicks.clone(); + // Off the origin, because a fresh test window's pointer sits at + // (0, 0) and a row under it would start out hovered. + div().pt(px(50.)).pl(px(50.)).child( + div() + .id(id.clone()) + .group(id.clone()) + .relative() + .w(px(300.)) + .h(px(ROW_H)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + .child( + action_strip(&id, 0x00EEEEEE).child( + div() + .w(px(TILE_SIZE_XS)) + .h(px(TILE_SIZE_XS)) + .debug_selector(|| "scm-action".into()), + ), + ), + ) + } + } + + fn hover(vcx: &mut VisualTestContext, at: Point) { + vcx.update(|window, cx| { + window.dispatch_event( + PlatformInput::MouseMove(MouseMoveEvent { + position: at, + pressed_button: None, + modifiers: Modifiers::none(), + }), + cx, + ); + window.refresh(); + }); + vcx.run_until_parked(); + } + + fn click(vcx: &mut VisualTestContext, at: Point) { + hover(vcx, at); + vcx.update(|window, cx| { + window.dispatch_event( + PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Left, + position: at, + modifiers: Modifiers::none(), + click_count: 1, + first_mouse: false, + }), + cx, + ); + window.dispatch_event( + PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: at, + modifiers: Modifiers::none(), + click_count: 1, + }), + cx, + ); + window.refresh(); + }); + vcx.run_until_parked(); + } + + #[gpui::test] + fn the_strip_survives_the_pointer_reaching_it(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Row::default()); + let mut vcx = VisualTestContext::from_window(window.into(), cx); + vcx.run_until_parked(); + assert!( + vcx.debug_bounds("scm-action").is_none(), + "an unhovered row draws no buttons" + ); + + hover(&mut vcx, point(px(70.), px(62.))); + let strip = vcx + .debug_bounds("scm-action") + .expect("hovering the row reveals the buttons"); + + // The whole point of the strip: the pointer travels right, onto it. + hover(&mut vcx, strip.center()); + assert!( + vcx.debug_bounds("scm-action").is_some(), + "the buttons are still drawn with the pointer on top of them" + ); + } + + /// What `occlude()` used to buy, and what replaced it. + #[gpui::test] + fn a_click_on_the_strip_never_reaches_the_row(cx: &mut TestAppContext) { + let row = Row::default(); + let clicks = row.row_clicks.clone(); + let window = cx.add_window(|_, _| row); + let mut vcx = VisualTestContext::from_window(window.into(), cx); + vcx.run_until_parked(); + + click(&mut vcx, point(px(70.), px(62.))); + assert_eq!(clicks.get(), 1, "clicking the row opens the diff"); + + let strip = vcx + .debug_bounds("scm-action") + .expect("the row is hovered, so the strip is up"); + click(&mut vcx, strip.center()); + assert_eq!( + clicks.get(), + 1, + "but a click on the strip belongs to the strip" + ); + } +} diff --git a/src/ui/scm/path.rs b/src/ui/scm/path.rs new file mode 100644 index 00000000..c8485d46 --- /dev/null +++ b/src/ui/scm/path.rs @@ -0,0 +1,201 @@ +//! Turning a repo-relative path and a timestamp into something that fits in a +//! 260px column. All pure, all cheap, all unit-tested — the panel calls these +//! once per visible row per frame. + +use std::borrow::Cow; + +/// The ellipsis every eliding function here uses. One `char`, so a budget in +/// characters is a budget the caller can reason about. +const ELLIPSIS: char = '…'; + +/// Split `src/ui/app.rs` into `("app.rs", "src/ui")`. +/// +/// The panel renders these as two runs with different sizes and colours, so +/// they have to come back as separate slices rather than one pre-joined +/// string. A path with no directory gets an empty second half. +pub(crate) fn split_display_path(rel: &str) -> (&str, &str) { + // A trailing slash means the caller handed us a directory; the last + // component is still the name, so drop the slash before splitting. + let trimmed = rel.strip_suffix('/').unwrap_or(rel); + match trimmed.rsplit_once('/') { + Some((dir, name)) => (name, dir), + None => (trimmed, ""), + } +} + +/// Keep the head and the tail, drop the middle. Paths and branch names both +/// carry their meaning at the ends — `feature/…/auth-retry` still says which +/// area and which change, where a plain truncate says neither. +/// +/// `max_chars` counts characters including the ellipsis, so the result never +/// renders wider than the caller budgeted. Cuts land on character boundaries by +/// construction: everything here walks `chars()`, never bytes. +pub(crate) fn elide_middle(s: &str, max_chars: usize) -> Cow<'_, str> { + let total = s.chars().count(); + if total <= max_chars { + return Cow::Borrowed(s); + } + // Below three there is no room for head + ellipsis + tail; fall back to a + // plain head cut rather than returning something wider than asked for. + if max_chars <= 2 { + return Cow::Owned(s.chars().take(max_chars).collect()); + } + let keep = max_chars - 1; + // Bias the extra character to the head: the tail is usually a file name, + // and its last few characters (the extension) repeat across rows anyway. + let head = keep.div_ceil(2); + let tail = keep - head; + let mut out = String::with_capacity(s.len()); + out.extend(s.chars().take(head)); + out.push(ELLIPSIS); + out.extend(s.chars().skip(total - tail)); + Cow::Owned(out) +} + +const MINUTE: i64 = 60; +const HOUR: i64 = 60 * MINUTE; +const DAY: i64 = 24 * HOUR; +/// Calendar-average, so "12mo" and "1y" describe the same distance instead of +/// leaving a gap where 365 days is neither. +const MONTH: i64 = DAY * 30; +const YEAR: i64 = DAY * 365; + +/// `"2h"` / `"3d"` / `"5mo"` — a graph row has about 26px for this. +/// +/// Through the i18n table like `home::relative_time`, in the compact spelling +/// this column's width demands — "now" is still an English word, and the row, +/// the tooltip, the detail byline and the overlay header all read it. +/// +/// `now` is a parameter rather than a clock read so the whole thing stays a +/// pure function, and so a test can sit exactly on a boundary. +pub(crate) fn relative_time(now_unix: i64, then_unix: i64) -> String { + use crate::ui::i18n::{L10nKey, t, t_fmt}; + // A commit stamped in the future (clock skew across machines is routine in + // a shared repo) reads as "now" rather than as a negative age. + let delta = (now_unix - then_unix).max(0); + let unit = |key: L10nKey, n: i64| t_fmt(key, &[("n", &n.to_string())]); + match delta { + d if d < MINUTE => t(L10nKey::ScmTimeNow).to_string(), + d if d < HOUR => unit(L10nKey::ScmTimeMinutes, d / MINUTE), + d if d < DAY => unit(L10nKey::ScmTimeHours, d / HOUR), + d if d < MONTH => unit(L10nKey::ScmTimeDays, d / DAY), + d if d < YEAR => unit(L10nKey::ScmTimeMonths, d / MONTH), + d => unit(L10nKey::ScmTimeYears, d / YEAR), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_display_path_separates_the_name_from_its_directory() { + assert_eq!(split_display_path("src/ui/app.rs"), ("app.rs", "src/ui")); + assert_eq!(split_display_path("README.md"), ("README.md", "")); + assert_eq!(split_display_path("a/b"), ("b", "a")); + assert_eq!(split_display_path(""), ("", "")); + } + + #[test] + fn split_display_path_ignores_a_trailing_slash() { + assert_eq!(split_display_path("src/ui/"), ("ui", "src")); + assert_eq!(split_display_path("src/"), ("src", "")); + // A leading slash leaves an empty directory half rather than dropping + // the root — the caller decides how to render that. + assert_eq!(split_display_path("/etc"), ("etc", "")); + } + + #[test] + fn elide_middle_leaves_short_strings_borrowed() { + assert!(matches!(elide_middle("short", 10), Cow::Borrowed("short"))); + assert!(matches!(elide_middle("exact", 5), Cow::Borrowed("exact"))); + } + + #[test] + fn elide_middle_keeps_both_ends_and_respects_the_budget() { + let out = elide_middle("crates/tty7-core/src/core/git/status.rs", 20); + assert_eq!(out.chars().count(), 20); + assert_eq!(out.matches(ELLIPSIS).count(), 1); + assert!(out.starts_with("crates/"), "{out}"); + assert!(out.ends_with("status.rs"), "{out}"); + } + + #[test] + fn elide_middle_spends_exactly_one_char_on_the_ellipsis() { + // U+2026, not three ASCII dots: three dots would eat three columns of + // a budget measured in characters. + let out = elide_middle("abcdefghij", 5); + assert_eq!(out, "ab…ij"); + assert_eq!(out.chars().count(), 5); + // An odd budget gives the head the spare character. + assert_eq!(elide_middle("abcdefghij", 6), "abc…ij"); + } + + #[test] + fn elide_middle_handles_degenerate_budgets() { + assert_eq!(elide_middle("abcdef", 3), "a…f"); + assert_eq!(elide_middle("abcdef", 2), "ab"); + assert_eq!(elide_middle("abcdef", 1), "a"); + assert_eq!(elide_middle("abcdef", 0), ""); + } + + #[test] + fn elide_middle_never_cuts_a_multibyte_char_in_half() { + // Every one of these is 3 bytes; a byte-indexed implementation panics + // here rather than returning something wrong, which is why this test + // asserts on the value and not just on not panicking. + let path = "文档/设计/源代码管理方案.md"; + for budget in 0..=path.chars().count() + 2 { + let out = elide_middle(path, budget); + assert!( + out.chars().count() <= budget, + "budget {budget} produced {out:?}" + ); + } + let out = elide_middle(path, 8); + assert_eq!(out.chars().count(), 8); + assert!(out.contains(ELLIPSIS)); + assert!(out.starts_with("文档/设"), "{out}"); + assert!(out.ends_with(".md"), "{out}"); + } + + #[test] + fn relative_time_covers_every_bucket() { + crate::ui::i18n::set_locale("en"); + let now = 1_800_000_000i64; + let ago = |secs: i64| relative_time(now, now - secs); + assert_eq!(ago(0), "now"); + assert_eq!(ago(59), "now"); + assert_eq!(ago(60), "1m"); + assert_eq!(ago(90), "1m"); + assert_eq!(ago(59 * MINUTE), "59m"); + assert_eq!(ago(HOUR), "1h"); + assert_eq!(ago(23 * HOUR), "23h"); + assert_eq!(ago(DAY), "1d"); + assert_eq!(ago(29 * DAY), "29d"); + assert_eq!(ago(MONTH), "1mo"); + assert_eq!(ago(YEAR - 1), "12mo"); + assert_eq!(ago(YEAR), "1y"); + assert_eq!(ago(5 * YEAR), "5y"); + } + + #[test] + fn relative_time_clamps_commits_from_the_future() { + crate::ui::i18n::set_locale("en"); + let now = 1_800_000_000i64; + assert_eq!(relative_time(now, now + DAY), "now"); + } + + /// The row, the tooltip and the overlay byline all read this — it goes + /// through the i18n table like `home::relative_time`, in compact form. + #[test] + fn relative_time_speaks_the_ui_language() { + crate::ui::i18n::set_locale("zh-CN"); + let now = 1_800_000_000i64; + assert_eq!(relative_time(now, now), "刚刚"); + assert_eq!(relative_time(now, now - 2 * HOUR), "2时"); + // “3月”会被读成月份名,所以是“个月”。 + assert_eq!(relative_time(now, now - 3 * MONTH), "3个月"); + crate::ui::i18n::set_locale("en"); + } +} diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs new file mode 100644 index 00000000..d31e34f0 --- /dev/null +++ b/src/ui/scm/state.rs @@ -0,0 +1,311 @@ +//! Everything the source control panel remembers between frames. +//! +//! All of it is app-level, hanging off `Tty7App.scm` rather than off a tab. +//! The panel has exactly one instance per window — the same model +//! `RightPanelState.diff_cwd` already uses. `Tab.diff_overlay` and `Tab.code` +//! are per-tab because they are full-screen overlays that belong to a tab; a +//! side panel does not. +//! +//! The one thing that must survive everything is the commit draft, so it is +//! keyed by repository rather than by tab or pane: a working tree has one +//! pending message, no matter how many panes are looking at it. + +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, CommitPage, GraphScope}; +use tty7_core::core::git::status::HeadState; + +use crate::ui::host_ops::HostId; + +/// Which working tree a piece of state belongs to. +/// +/// The host is part of the key because the same path can exist on this machine +/// and on three different remotes at once, and they are unrelated repositories. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub(crate) struct RepoKey { + pub(crate) host: HostId, + pub(crate) root: PathBuf, +} + +/// The four sections of the file list, in the order they are rendered. +/// +/// `Merge` only appears while a merge is unresolved, which is why the panel +/// asks for it by variant rather than always drawing a header. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum ScmGroup { + Merge, + Staged, + Changes, + Untracked, +} + +impl ScmGroup { + pub(crate) const ORDER: [ScmGroup; 4] = [ + ScmGroup::Merge, + ScmGroup::Staged, + ScmGroup::Changes, + ScmGroup::Untracked, + ]; +} + +#[derive(Default)] +pub(crate) struct ScmPanelState { + /// Which repository the panel is showing. Follows the active pane unless + /// `repo_override` says otherwise. + pub(crate) repo: Option, + /// Set when the user picks a repository from the multi-repo dropdown, and + /// cleared whenever the active tab changes — an explicit choice should + /// outlive a pane switch inside one tab, not a jump to somewhere else. + pub(crate) repo_override: Option, + /// The tab the override was made on, so the jump away can be noticed. + pub(crate) override_tab: Option, + /// Local branch names per repository, with the epoch they were read at. + /// Anything that could have moved a ref bumps the epoch, so the list in + /// the switcher is never older than the last operation. + pub(crate) branches: HashMap)>, + pub(crate) branches_loading: HashSet, + /// The inline "name your branch" input, present only while it is open. + pub(crate) new_branch: Option>, + /// Unsent commit messages, one per working tree. + pub(crate) drafts: HashMap, + /// The commit box. `None` until the panel has been rendered once: an + /// `InputState` needs a real window to be created in, and this struct is + /// built by `Default` alongside the rest of `Tty7App`. + pub(crate) commit_input: Option>, + /// Which repository's draft the box is currently holding. A change here + /// is what moves one draft out and the next one in. + pub(crate) commit_repo: Option, + /// A commit that has been dispatched: the repository, what HEAD was + /// before it, and the message it carried. Held until HEAD moves, so a + /// commit a hook rejects leaves the message in the box. + pub(crate) committing: Option<(RepoKey, HeadState, String)>, + /// Whether the next commit rewrites HEAD. Armed from the commit dropdown + /// rather than a checkbox row — 260px does not have a row to spare. + pub(crate) amend: bool, + /// Groups the user folded shut, and the ones whose fold state they have + /// set at all. Both are needed: a group nobody has touched follows the + /// default for its size (a thousand untracked files start folded), and + /// opening one by hand has to outlast the next file landing in it. + pub(crate) collapsed: HashSet, + pub(crate) toggled: HashSet, + /// Working directory → the repository root containing it, or `None` when + /// there is none, with when the answer was given. The root is what every + /// write runs from and what every cache is keyed by, so it is resolved + /// once per directory and reused. + pub(crate) roots: HashMap<(HostId, PathBuf), (std::time::Instant, Option)>, + pub(crate) root_lookups: HashSet<(HostId, PathBuf)>, + /// When the panel last asked for a status that it did not get back. + pub(crate) probe_attempt: HashMap<(HostId, PathBuf), std::time::Instant>, + /// The status the last frame drew, as (cache key, `Arc` identity). The + /// watcher compares against it so a global write that changed nothing does + /// not ask for another frame. + pub(crate) seen: Option<((HostId, PathBuf), usize)>, + pub(crate) watch: Option, + /// The cheap per-tab git status the panel last reacted to. A change in it + /// means a command touched the repository and the expensive status is due + /// another look. + pub(crate) last_tab_status: Option, + pub(crate) graph: GraphState, + /// When set, the panel body is replaced by a single commit's detail view + /// instead of the working tree. + pub(crate) detail: Option, + pub(crate) scroll: gpui::ScrollHandle, +} + +impl ScmPanelState { + /// The repository the panel should act on: an explicit pick wins over + /// whatever the active pane happens to be sitting in. + pub(crate) fn active_repo(&self) -> Option<&RepoKey> { + self.repo_override.as_ref().or(self.repo.as_ref()) + } + + /// Drop everything keyed to a host that left the registry. Without this, + /// `roots` and friends grow one entry per directory ever visited on a + /// link that no longer exists. Drafts survive on purpose: a reconnect + /// brings the same repositories back, and unsent messages with them. + pub(crate) fn forget_host(&mut self, host: HostId) { + self.roots.retain(|(h, _), _| *h != host); + self.root_lookups.retain(|(h, _)| *h != host); + self.probe_attempt.retain(|(h, _), _| *h != host); + self.branches.retain(|k, _| k.host != host); + self.branches_loading.retain(|k| k.host != host); + if self.repo_override.as_ref().is_some_and(|k| k.host == host) { + self.repo_override = None; + self.override_tab = None; + } + } + + /// A probe just answered "no repository" for `root`: every directory that + /// resolved to it has to re-ask. Left cached, the panel keeps mapping the + /// cwd to a repository that is gone and draws its Loading state forever. + pub(crate) fn forget_root(&mut self, host: HostId, root: &std::path::Path) { + self.roots + .retain(|(h, _), (_, held)| !(*h == host && held.as_deref() == Some(root))); + } + + pub(crate) fn draft(&self, repo: &RepoKey) -> &str { + self.drafts.get(repo).map(String::as_str).unwrap_or("") + } + + /// Whether a group renders folded. `count` decides it only for a group the + /// user has never touched. + pub(crate) fn group_collapsed(&self, group: ScmGroup, count: usize) -> bool { + if self.toggled.contains(&group) { + self.collapsed.contains(&group) + } else { + crate::ui::scm::panel::starts_collapsed(group, count) + } + } + + pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) { + self.toggled.insert(group); + if collapsed { + self.collapsed.insert(group); + } else { + self.collapsed.remove(&group); + } + } +} + +#[derive(Default)] +pub(crate) struct GraphState { + /// Mirrors `Config::scm_graph_expanded`, which starts `false`: the history + /// section unfurling on first open would make the panel look like a mess + /// nobody asked for. + pub(crate) expanded: bool, + /// How many commits have been asked for so far. Paging grows this and + /// re-runs the query rather than using `--skip`, which is O(skip) to walk + /// and shifts under you when a ref moves between pages. + pub(crate) requested: usize, + pub(crate) loading: bool, + /// The page the graph is currently drawn from, and which repository and + /// query produced it. `Arc` because paint reads it while the next page is + /// being laid out on a worker, and the key so a repository switch shows an + /// empty graph rather than the previous repository's history. + pub(crate) page: Option>, + pub(crate) page_key: Option<(RepoKey, u64, GraphScope)>, + /// The key of a load that came back empty-handed. Without it a failing + /// `git log` — a scope pinned to a since-deleted branch, a vanished + /// repository — would be retried from every frame's render, one git + /// process per notify, forever. The failure clears itself the moment the + /// key changes: an epoch bump (any refresh), a new scope, a new repo. + pub(crate) failed_key: Option<(RepoKey, u64, GraphScope)>, + /// Filter box. Like `commit_input`, created on first render — and with the + /// subscription that turns typing into a repaint. An `InputState` is its + /// own entity; without this the box would take text the list never sees. + pub(crate) search: Option>, + pub(crate) search_sub: Option, + /// Which commit indices the list shows, cached per (page identity, query). + /// The filter case-folds every subject and author; re-running that over + /// 5000 commits on every frame while the box is open is real work, and + /// even the unfiltered identity list is 40KB of indices a frame. + pub(crate) filter_cache: Option<(Option, usize, Arc>)>, + /// An open "name a branch at this commit" input, and the rev it starts + /// from. The panel's own naming row cannot serve this: it always creates + /// at HEAD, and the whole point here is the commit under the cursor. + pub(crate) naming: Option<(Entity, String)>, + /// Which refs the graph walks from. Three states rather than an + /// `Option`: "this branch and its upstream", "one named branch", + /// and "everything" are all reachable from the header's dropdown, and only + /// the enum the data layer already takes can express all three. + pub(crate) scope: GraphScope, + /// The selected row, by full sha. + pub(crate) selected: Option, + pub(crate) scroll: gpui::ScrollHandle, + /// Height of the history section, and whether its divider is being + /// dragged. Shaped like `right_panel_width` / `right_panel_dragging` so + /// the same drag code works on the other axis. + pub(crate) height: std::rc::Rc>, + pub(crate) dragging: std::rc::Rc>, +} + +/// The panel's second-level view: one commit's metadata and the files it +/// 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>>, + /// The file read came back with nothing — git errored, or the link + /// dropped mid-read. Distinct from "still loading", and from an empty + /// list: "0 files changed" for a commit whose files could not be read + /// would be a confident lie. + pub(crate) files_failed: bool, + /// 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, + files_failed: false, + body_expanded: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(root: &str) -> RepoKey { + RepoKey { + host: HostId::LOCAL, + root: PathBuf::from(root), + } + } + + #[test] + fn an_explicit_repo_pick_outranks_the_active_pane() { + let mut state = ScmPanelState::default(); + assert!(state.active_repo().is_none()); + state.repo = Some(key("/a")); + assert_eq!(state.active_repo(), Some(&key("/a"))); + state.repo_override = Some(key("/b")); + assert_eq!(state.active_repo(), Some(&key("/b"))); + state.repo_override = None; + assert_eq!(state.active_repo(), Some(&key("/a"))); + } + + #[test] + fn drafts_are_keyed_by_repository_not_by_path_alone() { + let mut state = ScmPanelState::default(); + state.drafts.insert(key("/a"), "wip".into()); + assert_eq!(state.draft(&key("/a")), "wip"); + assert_eq!(state.draft(&key("/b")), ""); + } +} diff --git a/src/ui/scm/status.rs b/src/ui/scm/status.rs new file mode 100644 index 00000000..038d9987 --- /dev/null +++ b/src/ui/scm/status.rs @@ -0,0 +1,128 @@ +//! One definition of how a git status looks, shared by the source control +//! panel, the file tree and the diff overlay's file cards. +//! +//! Keeping it in one place is the point: three hand-written A/M/D/R tables +//! drift, and the drift is invisible until someone notices the same file wears +//! two different letters in two different places. + +use gpui_component::ActiveTheme as _; +use tty7_core::core::git::status::DecoStatus; + +/// The single letter shown in the 14px badge column. `Ignored` has none — a +/// tree full of `!` is noise, not information. +pub(crate) fn status_glyph(s: DecoStatus) -> &'static str { + match s { + DecoStatus::Ignored => "", + DecoStatus::Untracked => "?", + DecoStatus::Added => "A", + DecoStatus::Modified => "M", + DecoStatus::Renamed => "R", + DecoStatus::Deleted => "D", + DecoStatus::Conflict => "U", + } +} + +/// Every colour here comes from `Semantics` (ansi 1/2/3/6 pushed over the +/// contrast floor), so it already tracks the theme and is already covered by +/// the contrast tests in `presets.rs`. No new token is introduced. +pub(crate) fn status_color(s: DecoStatus, cx: &gpui::App) -> gpui::Hsla { + let theme = cx.theme(); + match s { + DecoStatus::Conflict => theme.danger, + // Muted rather than danger: a deleted file is gone, not broken, and + // the strikethrough on its name already carries the message. + DecoStatus::Deleted => theme.muted_foreground, + DecoStatus::Added | DecoStatus::Untracked => theme.success, + DecoStatus::Modified => theme.warning, + DecoStatus::Renamed => theme.info, + DecoStatus::Ignored => theme.muted_foreground.opacity(0.7), + } +} + +/// Display precedence for rolling a directory up to one status: the worst of +/// everything beneath it wins. +/// +/// This is `DecoStatus`'s own `Ord` spelled out rather than a second opinion — +/// `StatusIndex` already rolls directories up with `max()`, so a rank that +/// disagreed would make a folder and the file inside it contradict each other. +/// Written out longhand so reordering the enum trips the test below instead of +/// silently reshuffling the UI. +pub(crate) fn status_rank(s: DecoStatus) -> u8 { + match s { + DecoStatus::Ignored => 0, + DecoStatus::Untracked => 1, + DecoStatus::Added => 2, + DecoStatus::Modified => 3, + DecoStatus::Renamed => 4, + DecoStatus::Deleted => 5, + DecoStatus::Conflict => 6, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: [DecoStatus; 7] = [ + DecoStatus::Ignored, + DecoStatus::Untracked, + DecoStatus::Added, + DecoStatus::Modified, + DecoStatus::Renamed, + DecoStatus::Deleted, + DecoStatus::Conflict, + ]; + + #[test] + fn every_status_has_its_own_letter() { + let mut seen: Vec<&str> = Vec::new(); + for s in ALL { + let glyph = status_glyph(s); + if s == DecoStatus::Ignored { + assert_eq!(glyph, "", "ignored files carry no letter"); + continue; + } + assert_eq!(glyph.chars().count(), 1, "{s:?} should be one character"); + assert!(!seen.contains(&glyph), "{glyph} is used twice"); + seen.push(glyph); + } + assert_eq!(status_glyph(DecoStatus::Conflict), "U"); + assert_eq!(status_glyph(DecoStatus::Untracked), "?"); + } + + #[test] + fn rank_orders_conflict_above_everything_and_ignored_below() { + let worst_first = [ + DecoStatus::Conflict, + DecoStatus::Deleted, + DecoStatus::Renamed, + DecoStatus::Modified, + DecoStatus::Added, + DecoStatus::Untracked, + DecoStatus::Ignored, + ]; + for pair in worst_first.windows(2) { + assert!( + status_rank(pair[0]) > status_rank(pair[1]), + "{:?} should outrank {:?}", + pair[0], + pair[1] + ); + } + } + + #[test] + fn rank_agrees_with_the_enums_own_ordering() { + // The data layer sorts by `Ord`; the UI sorts by `status_rank`. If the + // two ever disagree a directory rollup and a file row can disagree too. + for a in ALL { + for b in ALL { + assert_eq!( + status_rank(a).cmp(&status_rank(b)), + a.cmp(&b), + "{a:?} vs {b:?}" + ); + } + } + } +} diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 6e3d702a..18a2d781 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -20,7 +20,7 @@ use crate::daemon::protocol::{ }; use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe_local_name}; use crate::terminal::RemoteTerminal; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::{META, TEXT}; @@ -1111,12 +1111,21 @@ impl Tty7App { fn sftp_controls(&self, cx: &mut Context) -> AnyElement { let history = self.sftp_panel.show_history; + // The title bar's 24px chrome tile, the same one the Info tab puts its + // cwd actions in. It used to be built by hand — a 32px tile forced to + // 24 and then set `.xsmall()`, which overrode the 13px each icon below + // asked for with the button size's own 12, so the glyph never was the + // size the code claimed. `chrome_tile_sized` derives it from the tile + // instead, which is what every other 24px tile in the panel does. let tile = |button: Button, selected: bool, cx: &mut Context| { - crate::ui::tab_strip::chrome_tile(button, selected, cx) - .xsmall() - .w(px(24.)) - .h(px(24.)) - .rounded_md() + crate::ui::tab_strip::chrome_tile_sized( + button, + TILE_SIZE_SM, + TILE_GLYPH_SM, + selected, + cx, + ) + .rounded_md() }; h_flex() @@ -1126,7 +1135,7 @@ impl Tty7App { div().occlude().child( tile( Button::new("panel-sftp-refresh") - .icon(Icon::empty().path("icons/refresh.svg").size(px(13.))), + .icon(Icon::empty().path("icons/refresh.svg")), false, cx, ) @@ -1138,7 +1147,7 @@ impl Tty7App { div().occlude().child( tile( Button::new("panel-sftp-menu") - .icon(Icon::empty().path("icons/ellipsis.svg").size(px(13.))), + .icon(Icon::empty().path("icons/ellipsis.svg")), false, cx, ) diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index e3e0734e..66cdb206 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -748,13 +748,17 @@ impl Tty7App { pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { let active_tab = self.right_panel_tab; - let changed = match &self.right_panel.diff { - Some(Some(snap)) => { - let n = snap.files.len() + snap.untracked_count(); - (n > 0).then_some(n) - } - _ => None, - }; + // The count the source control tile carries. It reads the same status + // the panel draws, so the badge and the group headers can never + // disagree — and it counts entries, not files, because a path that is + // both staged and modified is two things to do, which is what the + // groups show. + let changed = self + .scm + .active_repo() + .and_then(|repo| crate::terminal::git_data::status_of(cx, repo.host, &repo.root)) + .map(|status| status.entries.len()) + .filter(|n| *n > 0); [ ( RightPanelTab::Info, @@ -762,7 +766,7 @@ impl Tty7App { L10nKey::PanelInfoTitle, ), ( - RightPanelTab::Changes, + RightPanelTab::Scm, Icon::empty().path("icons/git-branch.svg"), L10nKey::PanelChangesTitle, ), @@ -785,7 +789,7 @@ impl Tty7App { ) .rounded_lg() .tooltip(match (tab, changed) { - (RightPanelTab::Changes, Some(n)) => { + (RightPanelTab::Scm, Some(n)) => { SharedString::from(format!("{} · {n}", t(label_key))) } _ => SharedString::from(t(label_key)), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index b33d6f09..fb71d0fa 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -598,6 +598,10 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { }); cx.set_global(surfaces.clone()); cx.set_global(presets::ActiveAccent(m.accent)); + // Same treatment as `Surfaces`: derived once here rather than recomputed + // in `render`, because the graph reads it once per visible row per frame + // and each entry costs a contrast bisection on three surfaces. + cx.set_global(presets::ActiveLanes(theme.lanes())); let t = Theme::global_mut(cx); let mut base: Hsla = rgb(m.background).into();