From c3233bb39bcbb9819bfcc2cf65de2a11423239b2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:13:08 +0800 Subject: [PATCH 01/36] refactor(git): split core::git into a module tree and move the diff model into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source control work needs four kinds of git knowledge — how to run a process, what the working tree looks like, what a patch looks like, and what the history looks like — and they were about to pile into one 386-line file. core/git.rs becomes core/git/{mod,status,diff,log,ops}.rs. Every existing import path is unchanged: mod.rs still holds the process layer and re-exports nothing it did not already export. src/terminal/git_diff.rs moves wholesale into core::git::diff, leaving a pub-use shim so diff_overlay.rs and right_panel.rs compile untouched. It never had a gpui dependency, and the headless server should parse a patch with the same code the GUI does. Also lands the shared pieces the panel is built on, all inert for now: - RecordSplitter, LineSplitter's sibling for the two git formats that are not newline delimited (porcelain v2 -z, and log with an ASCII record separator). It hands out &[u8] because a path in a -z status need not be UTF-8. - git_output_with_env, so network operations can be told they have no terminal to prompt at without the read paths inheriting that. - The status/log/ops type contracts: the XY pair, unmerged stages, HeadState, RepoPath (which refuses to produce a pathspec it cannot represent), the row-local graph edge model, and GitOp with its destructive() policy datum. No behaviour changes. 867 core and 1036 app tests still pass. --- crates/tty7-core/Cargo.toml | 6 + crates/tty7-core/src/core/git/diff.rs | 732 +++++++++++++++++ crates/tty7-core/src/core/git/log.rs | 173 ++++ .../tty7-core/src/core/{git.rs => git/mod.rs} | 105 +++ crates/tty7-core/src/core/git/ops.rs | 221 ++++++ crates/tty7-core/src/core/git/status.rs | 427 ++++++++++ src/terminal/git_diff.rs | 738 +----------------- src/terminal/git_status.rs | 2 +- 8 files changed, 1671 insertions(+), 733 deletions(-) create mode 100644 crates/tty7-core/src/core/git/diff.rs create mode 100644 crates/tty7-core/src/core/git/log.rs rename crates/tty7-core/src/core/{git.rs => git/mod.rs} (75%) create mode 100644 crates/tty7-core/src/core/git/ops.rs create mode 100644 crates/tty7-core/src/core/git/status.rs 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/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs new file mode 100644 index 00000000..5bf692a3 --- /dev/null +++ b/crates/tty7-core/src/core/git/diff.rs @@ -0,0 +1,732 @@ +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; + +#[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::git(host, cwd, &["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); + let branch = git::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 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..e6c0d78a --- /dev/null +++ b/crates/tty7-core/src/core/git/log.rs @@ -0,0 +1,173 @@ +//! 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 smallvec::SmallVec; + +/// 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; +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. +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, +} + +#[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, +} + +// `LaneAlloc` — the append-only lane assigner these rows come out of — is +// defined below by the graph layout pass. It is append-only by design: a later +// page extends the graph without reflowing what is already on screen. diff --git a/crates/tty7-core/src/core/git.rs b/crates/tty7-core/src/core/git/mod.rs similarity index 75% rename from crates/tty7-core/src/core/git.rs rename to crates/tty7-core/src/core/git/mod.rs index eda88e62..deeca65a 100644 --- a/crates/tty7-core/src/core/git.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -1,3 +1,19 @@ +//! 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}; @@ -94,6 +110,19 @@ pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { } pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { + git_output_with_env(cwd, args, &[]) +} + +/// `git_output` plus extra environment. Network operations (`fetch`/`pull`/ +/// `push`) need to be told they have no terminal to prompt at; read paths must +/// *not* inherit that, so the two share a body rather than a config. +/// +/// 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, @@ -104,12 +133,23 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { 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(), @@ -210,6 +250,71 @@ impl LineSplitter { } } +/// 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, + dropped: usize, +} + +pub const MAX_RECORD: usize = 1024 * 1024; + +impl RecordSplitter { + pub fn new(sep: u8) -> RecordSplitter { + RecordSplitter { + sep, + tail: Vec::new(), + 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); + if self.tail.is_empty() && self.dropped == 0 && record.len() <= MAX_RECORD { + on_record(record); + } else { + self.keep(record); + let joined = std::mem::take(&mut self.tail); + self.dropped = 0; + 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. + pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) { + if !self.tail.is_empty() { + let joined = std::mem::take(&mut self.tail); + on_record(&joined); + } + } + + /// How many bytes were discarded for overrunning [`MAX_RECORD`]. Non-zero + /// means the parse is incomplete and the caller should say so. + pub fn dropped(&self) -> usize { + self.dropped + } + + fn keep(&mut self, bytes: &[u8]) { + let room = MAX_RECORD.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)); 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..1bb886f1 --- /dev/null +++ b/crates/tty7-core/src/core/git/ops.rs @@ -0,0 +1,221 @@ +//! 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::PathBuf; + +/// 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; + +/// 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); + +/// 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, + 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, + GitOp::Reset { + mode: ResetMode::Hard, + .. + } => Destructive::LosesWorktreeEdits, + 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, + _ => &[], + } + } +} 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..45dd1e2b --- /dev/null +++ b/crates/tty7-core/src/core/git/status.rs @@ -0,0 +1,427 @@ +//! 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 -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::PathBuf; + +/// 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; + } + 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 { + 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); + 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; + } +} 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 f53fa4be..a52a1b7a 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)] From 3b3e5fa046c24872640b0c86ae4d6b6a2c17bafa Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:14:42 +0800 Subject: [PATCH 02/36] chore(ui): reserve the diff_rows and scm modules Two parallel strands of the source control work each need to add one line to ui/mod.rs. Landing both declarations up front keeps them from colliding over it. --- src/ui/diff_rows.rs | 5 +++++ src/ui/mod.rs | 2 ++ src/ui/scm/mod.rs | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 src/ui/diff_rows.rs create mode 100644 src/ui/scm/mod.rs diff --git a/src/ui/diff_rows.rs b/src/ui/diff_rows.rs new file mode 100644 index 00000000..f93f9f10 --- /dev/null +++ b/src/ui/diff_rows.rs @@ -0,0 +1,5 @@ +//! 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. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7be1962c..dd785d77 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_tree; pub mod forwards; pub mod hints; @@ -24,6 +25,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/scm/mod.rs b/src/ui/scm/mod.rs new file mode 100644 index 00000000..f238d5cd --- /dev/null +++ b/src/ui/scm/mod.rs @@ -0,0 +1,5 @@ +//! 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`. From bb9208cf3e4d211a9513d8ec2618487d879f6ee2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:26:10 +0800 Subject: [PATCH 03/36] feat(git): build and run the source control write operations Adds the three pieces ops.rs was missing: GitOp::commands, the pure argv table every write goes through; run_op, which executes those batches and turns a failure into something the UI can act on; and classify, which reads git's own words to decide what the failure was. Pathspecs always carry :(literal) and always sit after a --, so a file named a[b].txt, HEAD or -f names itself. Long lists split at 200 paths. Non-UTF-8 paths are rejected up front rather than sent lossily. The old command spellings throughout, never git restore, so one argv works on a CentOS 7 dev box and here alike; the only state-dependent branch is an unborn HEAD, and HeadState is a parameter so the table stays pure. --- crates/tty7-core/src/core/git/ops.rs | 1477 +++++++++++++++++++++++++- 1 file changed, 1476 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index 1bb886f1..9a83b8a2 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -10,7 +10,10 @@ //! from a test. What this module offers instead is [`GitOp::destructive`], the //! policy datum the UI gates on. -use std::path::PathBuf; +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. @@ -218,4 +221,1476 @@ impl GitOp { _ => &[], } } + + /// 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 a dev box on CentOS 7 (git 1.8) is a real thing — + /// 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] + } + 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()); + out.push(branch.clone()); + 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)?; + self.check_rev("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. + &["rm", "--cached", "-r", "-q"] + } +} + +/// `prefix -- `, split so no single argv can hit `E2BIG`. +/// +/// 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> { + specs + .chunks(MAX_PATHSPECS_PER_CALL) + .map(|chunk| { + let mut out = argv(prefix); + out.push("--".into()); + out.extend(chunk.iter().cloned()); + out + }) + .collect() +} + +/// The environment a network operation needs, for whoever spawns it. +/// +/// Without `GIT_TERMINAL_PROMPT=0` git opens `/dev/tty` directly when stdin is +/// closed, and in some environments it gets one and hangs on "Username for +/// ...". A `None` value means the variable is removed. +/// +/// This lives here next to the operations that need it, but only +/// `git_output_with_env` can apply it — `Host::git` takes no environment. It is +/// the host layer's job to reach for this. +pub fn network_env() -> &'static [(&'static str, Option<&'static str>)] { + &[ + ("GIT_TERMINAL_PROMPT", Some("0")), + ("GIT_ASKPASS", None), + ("SSH_ASKPASS", None), + ("SSH_ASKPASS_REQUIRE", Some("never")), + ] +} + +/// 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::>() + }; + + // Network operations still take the plain path: they want + // `GIT_NETWORK_DEADLINE` and `network_env`, and neither can be + // expressed through `Host::git`. Both arrive with the host layer's + // `git_with_deadline`. + let out = host.git(root, &borrowed).map_err(|err| GitOpError { + op: label, + kind: 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, + detail: if stderr.is_empty() { stdout } else { stderr }, + 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::*; + + 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()), + vec![vec!["rm", "--cached", "-r", "-q", "--", ":(literal)x"]], + ); + assert_eq!( + GitOp::UnstageAll.commands(&unborn()), + vec![vec!["rm", "--cached", "-r", "-q", "--", "."]], + ); + } + + #[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"); + } + + #[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()), + 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()), + vec![vec!["push", "origin", "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", "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) { + assert!( + !batch + .iter() + .any(|a| a == "--force" || a == "-f" && batch[0] != "clean"), + "{:?} 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() + ); + } + + #[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()); + } + } + + #[test] + fn the_network_environment_closes_every_prompt() { + let env = network_env(); + assert_eq!( + env.iter().find(|(k, _)| *k == "GIT_TERMINAL_PROMPT"), + Some(&("GIT_TERMINAL_PROMPT", Some("0"))), + ); + for key in ["GIT_ASKPASS", "SSH_ASKPASS"] { + assert_eq!( + env.iter().find(|(k, _)| *k == key).map(|(_, v)| *v), + Some(None), + "{key} has to be removed, not set", + ); + } + assert_eq!( + env.iter().find(|(k, _)| *k == "SSH_ASKPASS_REQUIRE"), + Some(&("SSH_ASKPASS_REQUIRE", Some("never"))), + ); + } + + 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); + } + } + + fn run(host: &dyn Host, dir: &Path, args: &[&str]) -> String { + let out = host.git(dir, args).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"]); + run(host, &dir, &["config", "user.email", "tty7@example.com"]); + run(host, &dir, &["config", "user.name", "tty7"]); + run(host, &dir, &["config", "commit.gpgsign", "false"]); + + 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"); + 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"]); + run(host, &dir, &["config", "user.email", "tty7@example.com"]); + run(host, &dir, &["config", "user.name", "tty7"]); + run(host, &dir, &["config", "commit.gpgsign", "false"]); + 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, + ); + } } From 9914a939b463cc7b41e8307230068a577bd0f63b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:27:31 +0800 Subject: [PATCH 04/36] chore(deps): regenerate the lock for tty7-core's smallvec The dependency was declared but the lock was never refreshed, so every worktree building against it produced the same one-line diff. --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 2368a7f3..392cdcd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9740,6 +9740,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "smallvec", "smol", "system-configuration", "system-configuration-sys", From 5b9c41555c3e1054018d94bd856f36acda26435a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:29:33 +0800 Subject: [PATCH 05/36] feat(host): let long git operations finish over a control link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `push`, `pull` and `fetch` run for as long as the network takes. A remote host sent them as a plain `Git` request, whose client-side deadline is the 20 seconds an interactive query gets, so anything slower came back as a timeout while the far side was still working. Add `Host::git_with_deadline`, a provided method that names the ceiling at the call site. `RemoteHost` sends the same `Git` request it always did and only widens its own patience, so the bytes on the wire are unchanged and a server that predates this serves it as-is: no new verb, no version bump. `LocalHost` does not override it — `Command::output()` has no timeout of its own, so forwarding to `git` already means "wait until git is done". Waiting longer is only useful if the thing we wait for cannot stop to ask a question. Give every git `LocalHost` spawns an environment that forbids prompting: nulled stdin was not enough, because without `GIT_TERMINAL_PROMPT` git opens `/dev/tty` itself. The read path carries the same environment rather than a separate one — `status` and `diff` have nothing to prompt about, and the wire has no bit that says "this request talks to a network", so the remote server has to reach the same rule from the same args to be protected at all. Guard the byte fidelity the SCM panel now leans on: `-z` output keeps its NULs through `Host::git` (`git_lines` would rejoin the records with newlines), and a `:(literal)` pathspec reaches git unrewritten. --- crates/tty7-core/src/host/conformance.rs | 110 +++++++++++++++++++++++ crates/tty7-core/src/host/local.rs | 42 ++++++++- crates/tty7-core/src/host/mod.rs | 24 +++++ crates/tty7-core/src/host/remote.rs | 19 ++++ crates/tty7-core/src/host/server.rs | 40 +++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/host/conformance.rs b/crates/tty7-core/src/host/conformance.rs index b08661c6..cc76fe8e 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,45 @@ 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 }; + + let configured = h.git( + &repo, + &[ + "config", + "alias.tty7prompt", + "!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]", + ], + ); + let Ok(out) = configured else { return }; + if !out.success() { + return; + } + let Ok(out) = h.git(&repo, &["tty7prompt"]) else { + return; + }; + if !out.success() { + return; + } + // 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 +720,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..3564342e 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -16,6 +16,46 @@ 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(); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + env.push(("GIT_SSH_COMMAND", Some("ssh -o BatchMode=yes"))); + } + env +} + pub struct LocalHost { gitignore: Arc>, } @@ -239,7 +279,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 872e2929..5a7bbba9 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -2184,6 +2184,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)); From df6f028685c19e9481f0ede732c7a595bfc5b6b9 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 9 Aug 2026 00:30:20 +0800 Subject: [PATCH 06/36] feat(git): parse porcelain v2 into a working tree status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the parser and the probe behind the source control panel: one `git status --porcelain=v2 --branch --show-stash -uall -z` per refresh, read through `Host::git` so the NUL stream survives a remote workspace byte for byte (`git_lines` would reassemble it into one giant line). - `parse_porcelain_v2` is pure and infallible: headers, `1`, `2`, `u`, `?` and `!` records, submodule sub-state, rename scores. A `2` record spends two NUL tokens, so the parser parks the half-built entry and takes the *next* record as the old path — a regression test asserts the row behind a rename is not swallowed. - `probe_status` costs three round trips: `rev-parse`, `status`, and one `read_dir` of the git dir for the sequencer operation. The operation precedence mirrors git's own `wt_status_print_state`; only a parked rebase pays for a second `read_dir` to tell `am` from rebase and `-i` from plain. - `StatusIndex::build` folds a status into the file tree's lookup, and drops the per-file map past `MAX_DECORATED_FILES`. Two behaviours were measured on git 2.50.1 and shaped the code: - `status.aheadBehind=false` does *not* suppress `# branch.ab` for porcelain v2 (the config is documented for non-porcelain formats), and `--no-ahead-behind` prints `+? -?` rather than dropping the line. So `+? -?` parses as *unknown*, never as in sync, and the `rev-list --left-right --count` fallback fires only when the line is missing — unconditionally re-asking would cost a remote RPC on every refresh for a number we almost always already have. - `-c core.quotePath=false` is a no-op under `-z`, which disables C-quoting on its own. It is kept for consistency with the other git invocations, not because it fixes anything here. `repo_home` becomes `pub(crate)` so the probe can reuse the one `rev-parse` that already resolves root, git dir and common dir. --- crates/tty7-core/src/core/git/mod.rs | 2 +- crates/tty7-core/src/core/git/status.rs | 1249 ++++++++++++++++++++++- 2 files changed, 1249 insertions(+), 2 deletions(-) diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index deeca65a..dcbe19e4 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -59,7 +59,7 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { }) } -fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { +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(); }; diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index 45dd1e2b..2969234b 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -13,7 +13,10 @@ //! them; the parser is just one producer. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +use super::RecordSplitter; +use crate::host::{Entry, Host}; /// A path relative to the repository root, always `/`-separated. /// @@ -391,6 +394,32 @@ pub struct StatusIndex { } 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 is no longer on disk, so no tree row will ask + // for it — but the directory it left did lose a file, and the + // rollup is the only place that can say so. + if let Some(orig) = &entry.orig_path { + index.insert(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() } @@ -425,3 +454,1221 @@ impl StatusIndex { 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)); + split.finish(|record| parser.record(record)); + parser.finish() +} + +#[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 }, + } +} + +/// The whole working tree state for the repository containing `cwd`, or `None` +/// if there is no repository there. +/// +/// 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) -> Option { + let paths = super::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 git_dir = lines.next(); + let home = super::repo_home(&root, git_dir, lines.next()); + let git_dir = PathBuf::from(git_dir?); + + let out = host.git(cwd, STATUS_ARGS).ok()?; + if !out.success() { + return None; + } + let mut parsed = parse_porcelain_v2(&out.stdout); + if parsed.ahead_behind.is_none() { + if 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)); + Some(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::*; + + /// 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" + ); + } + + #[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); + } + } + + fn scratch(name: &str) -> Option { + let dir = std::env::temp_dir().join(format!("tty7-scm-status-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok()?; + Some(Scratch(dir)) + } + + /// Runs git with the identity and signing settings pinned, so the test does + /// not depend on whatever is in the developer's `~/.gitconfig`. + fn run(host: &dyn Host, cwd: &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); + host.git(cwd, &full).map(|o| o.success()).unwrap_or(false) + } + + #[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 !run(&*host, repo, &["init", "--quiet"]) { + return; // no git on this machine + } + // 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"] + )); + 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 = probe_status(&*host, repo).expect("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()); + assert_eq!(status.root, 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 !run(&*host, repo, &["init", "--quiet"]) { + return; + } + assert!(run( + &*host, + repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + 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 = probe_status(&*host, repo).expect("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!(run(&*host, &repo, &["init", "--quiet"])); + assert!(run( + &*host, + &repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + 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 = probe_status(&*host, &repo).expect("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()); + } + + #[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), None); + assert_eq!(probe_status(&*host, Path::new("/no/such/tty7/path")), None); + } +} From b8757372b37ad39de6f366f5bb673d3a36ee90ec Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:34:59 +0800 Subject: [PATCH 07/36] feat(git): give network operations the long deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_op now routes fetch/pull/push through Host::git_with_deadline. The no-prompt environment turned out not to belong here at all — LocalHost puts it on every call, on both sides of the wire, so ops.rs's own copy of the same four variables was a second definition waiting to drift. Removed it; the conformance case git_terminal_prompt_is_disabled guards the behaviour across local and remote, which the unit test on the constant could not. Also corrects git_output_with_env's doc comment, which claimed read paths must not inherit the no-prompt environment. They do, deliberately: a read path never prompts, and a request arriving over the wire carries no bit saying which kind it is. --- crates/tty7-core/src/core/git/mod.rs | 11 ++++-- crates/tty7-core/src/core/git/ops.rs | 57 +++++----------------------- 2 files changed, 18 insertions(+), 50 deletions(-) diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index dcbe19e4..1e377597 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -113,9 +113,14 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { git_output_with_env(cwd, args, &[]) } -/// `git_output` plus extra environment. Network operations (`fetch`/`pull`/ -/// `push`) need to be told they have no terminal to prompt at; read paths must -/// *not* inherit that, so the two share a body rather than a config. +/// `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( diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index 9a83b8a2..7cda47da 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -514,24 +514,6 @@ fn batched(prefix: &[&str], specs: &[String]) -> Vec> { .collect() } -/// The environment a network operation needs, for whoever spawns it. -/// -/// Without `GIT_TERMINAL_PROMPT=0` git opens `/dev/tty` directly when stdin is -/// closed, and in some environments it gets one and hangs on "Username for -/// ...". A `None` value means the variable is removed. -/// -/// This lives here next to the operations that need it, but only -/// `git_output_with_env` can apply it — `Host::git` takes no environment. It is -/// the host layer's job to reach for this. -pub fn network_env() -> &'static [(&'static str, Option<&'static str>)] { - &[ - ("GIT_TERMINAL_PROMPT", Some("0")), - ("GIT_ASKPASS", None), - ("SSH_ASKPASS", None), - ("SSH_ASKPASS_REQUIRE", Some("never")), - ] -} - /// What a failure means, from git's own words. /// /// Substring matching on English output, checked in a fixed order because the @@ -637,11 +619,16 @@ pub fn run_op( .collect::>() }; - // Network operations still take the plain path: they want - // `GIT_NETWORK_DEADLINE` and `network_env`, and neither can be - // expressed through `Host::git`. Both arrive with the host layer's - // `git_with_deadline`. - let out = host.git(root, &borrowed).map_err(|err| GitOpError { + // A push over a slow link outlives the deadline a `Git` request gets + // by default, which is sized for interactive queries. The no-prompt + // environment is not this layer's business: `LocalHost` puts it on + // every call, on both sides of the wire. + let spawned = if op.is_network() { + host.git_with_deadline(root, &borrowed, GIT_NETWORK_DEADLINE) + } else { + host.git(root, &borrowed) + }; + let out = spawned.map_err(|err| GitOpError { op: label, kind: GitOpErrorKind::Spawn, message: err.to_string(), @@ -1353,30 +1340,6 @@ mod tests { } } - #[test] - fn the_network_environment_closes_every_prompt() { - let env = network_env(); - assert_eq!( - env.iter().find(|(k, _)| *k == "GIT_TERMINAL_PROMPT"), - Some(&("GIT_TERMINAL_PROMPT", Some("0"))), - ); - for key in ["GIT_ASKPASS", "SSH_ASKPASS"] { - assert_eq!( - env.iter().find(|(k, _)| *k == key).map(|(_, v)| *v), - Some(None), - "{key} has to be removed, not set", - ); - } - assert_eq!( - env.iter().find(|(k, _)| *k == "SSH_ASKPASS_REQUIRE"), - Some(&("SSH_ASKPASS_REQUIRE", Some("never"))), - ); - } - - fn kind_of(stderr: &str) -> GitOpErrorKind { - classify(stderr, Some(1)) - } - #[test] fn classify_recognizes_every_shape_of_credential_failure() { for stderr in [ From 6ab4799bc19306714fb2171b4574706d4387b29c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:37:28 +0800 Subject: [PATCH 08/36] fix(git): restore the classify test helper dropped with network_env Removing network_env took its neighbouring `kind_of` shorthand with it. The shared target directory hid this: a stale test binary from another worktree reported green twice before an isolated CARGO_TARGET_DIR showed the ten real errors. --- crates/tty7-core/src/core/git/ops.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index 7cda47da..d758719a 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -1340,6 +1340,10 @@ mod tests { } } + fn kind_of(stderr: &str) -> GitOpErrorKind { + classify(stderr, Some(1)) + } + #[test] fn classify_recognizes_every_shape_of_credential_failure() { for stderr in [ From b27d0d322bda2c6b0ff9c45c80cd87aca6e6c30d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:38:54 +0800 Subject: [PATCH 09/36] feat(git): lay out the commit history in lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three pieces the graph section needs from the data layer: the lane assigner, the `log --pretty` parser it is fed from, and the paged loader that puts the two together. `LaneAlloc` is append-only. It keeps only what has to survive a page boundary — which oid each lane is holding a place for, and the reverse index — so a later page extends the graph instead of re-flowing the rows already on screen. That is only possible because `GraphRow` is row-local: a row says nothing about the rows below it, which is also why a long-lived branch is drawn from its first row rather than staying invisible until the page holding its parent loads. Colour is the lane number, fixed when the lane is created. A per-branch counter wraps at the palette size and puts branch 0 and branch N in the same colour, which in a three-column panel is very likely two adjacent lines; keying on the lane makes neighbours distinct by construction. The "one branch, one colour" half falls out of the first parent inheriting its child's lane in place. Lanes are recycled but never compacted — compacting would move a lane out from under a row already drawn. The log is read with RS between records and US between fields rather than `-z`, whose record separator is NUL and so collides with the field separator; fields are taken with `splitn` so the body absorbs any US of its own. Paging is a larger `-n`, never `--skip`, and `HeadAndUpstream` resolves to shas first so a push between two pages cannot shift the window out from under page one. The Cargo.lock line is the missing half of tty7-core picking up smallvec, which main already carries. --- Cargo.lock | 1 + crates/tty7-core/src/core/git/log.rs | 1146 +++++++++++++++++++++++++- 2 files changed, 1144 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2368a7f3..392cdcd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9740,6 +9740,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "smallvec", "smol", "system-configuration", "system-configuration-sys", diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index e6c0d78a..b0079703 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -7,8 +7,14 @@ //! 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 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; @@ -168,6 +174,1140 @@ pub struct CommitPage { pub open_lanes: Vec, } -// `LaneAlloc` — the append-only lane assigner these rows come out of — is -// defined below by the graph layout pass. It is append-only by design: a later -// page extends the graph without reflowing what is already on screen. +/// 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. + + edges.sort_unstable_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)"; + +/// 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]) -> Vec { + let mut commits = Vec::new(); + let mut used = 0usize; + let mut on_record = |record: &[u8]| { + used = used.saturating_add(record.len()); + if used > MAX_LOG_BYTES { + return; + } + if let Some(commit) = parse_record(record) { + commits.push(commit); + } + }; + let mut split = RecordSplitter::new(REC_SEP); + split.push(stdout, &mut on_record); + split.finish(&mut on_record); + commits +} + +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, + }); + 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, + }) +} + +/// 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. `%(upstream)` and `%(objecttype)` are asked for because + // the branch switcher will want them off the same call; neither has a + // home on `RefDeco` yet. + let _short = fields.next(); + let _upstream = fields.next(); + 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(deco) = ref_deco(full, head == "*") { + 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(), + } +} + +/// 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", + "--parents", + "--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 mut commits = parse_log(&out.stdout); + let complete = 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. +/// +/// `HeadAndUpstream` resolves to shas first. Paging re-runs the walk with a +/// larger `-n`, and a symbolic `HEAD` 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. +fn scope_revs(host: &dyn Host, root: &Path, scope: &GraphScope) -> Vec { + match scope { + GraphScope::Head => vec!["HEAD".to_string()], + GraphScope::All => vec!["--all".to_string()], + GraphScope::Refs(refs) => { + let mut revs: Vec = 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('-')) + .cloned() + .collect(); + if revs.is_empty() { + revs.push("HEAD".to_string()); + } + revs + } + 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::*; + + 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. + 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 + } + + /// The property the whole layout rests on: at any horizontal cut through + /// the graph a lane carries at most one 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. + fn assert_lanes_line_up(rows: &[GraphRow]) { + for (i, row) in rows.iter().enumerate() { + for edges in [top(row), bottom(row)] { + let mut once = edges.clone(); + once.dedup(); + assert_eq!(once, edges, "row {i} has two lines on one lane: {row:?}"); + } + } + 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); + } + + #[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, + ]) + } + + #[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()); + + 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()); + + 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()).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()).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()); + + 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()); + + 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()).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 this_repo_lays_out_one_row_per_commit() { + let host = crate::host::local::LocalHost::new(); + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + // Graceful about not being in a repository at all: a source tarball is + // a legitimate place to run the tests from. + let Some(page) = load_page(&*host, here, &GraphScope::Head, 30) else { + return; + }; + + assert_eq!(page.rows.len(), page.commits.len()); + assert!(!page.commits.is_empty(), "this repo has commits"); + assert!(page.max_lanes >= 1); + 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); + + let page = load_page(&*host, here, &GraphScope::HeadAndUpstream, 5).unwrap(); + assert_eq!(page.commits.len(), 5); + assert!(!page.complete, "five commits is not the whole history"); + } +} From 58e8ba16d13e73f06655c1546586940858e022e4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:40:21 +0800 Subject: [PATCH 10/36] feat(git): ask for three kinds of diff, and lift the row model out of the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `probe` could only ever run `git diff HEAD`. A `DiffRequest` now names the source (worktree, staged, HEAD, one commit, a range), the pathspecs, the context width and the budget, and `DiffSource::args` is the single place any diff argv is built — which is also the single place to test it. Three things were verified against git 2.50.1 rather than assumed: - `-c core.quotePath=false` is a real bug fix, not tidiness. With quoting on, `diff --git "a/\344\270\255\346\226\207\345\220\215.txt" …`; off, `diff --git a/中文名.txt b/中文名.txt`. `parse_quoted_pair` does not decode octal, so every non-ASCII path in the overlay was simply wrong. `ls-files` quotes the same way, so the untracked listing gets the flag too. - `Commit` runs `log -p -1 --format= --first-parent`, not `diff-tree`. `diff-tree` does not honour `--first-parent` as a narrowing of a merge: over 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 `--root`. - A type change is not one patch. git emits a deletion and a creation for the same path, back to back, with nothing in either header saying why; the pair is folded back into a single `TypeChanged` entry. `old mode`/`new mode` only appears for permission changes, which stay `Modified`. `FileStatus` also gains `Copied` and `Unmerged`. A conflicted path arrives as a combined diff (`diff --cc`, `@@@`, one marker column per parent), so the body parser reads its marker width from the hunk header instead of assuming one. The budget is a parameter now (`DiffBudget::PANEL`, `::SINGLE_FILE`) rather than three module constants read inside the parser. Defaults are unchanged. On the UI side, the overlay's reuse test was `(cwd, host)` — clicking a staged file while a worktree overlay was open took the "just move the focus" branch and went on showing the unstaged patch under the staged file's name. The source is part of an overlay's identity now, in that filter, in the panel-seed shortcut and in the in-flight de-duplication key. `maybe_refresh_diff_overlay` only compares HEAD snapshots against the cached `--numstat HEAD` counts, which are the only counts they are comparable to. `split_hunk` and tab expansion move to `ui::diff_rows` alongside a new `unified_rows`, so the two renderings of a hunk are built and tested in one place, without a window. The overlay's file list finally hangs on tty7's own scrollbar instead of a bare `overflow_y_scroll`. --- crates/tty7-core/src/core/git/diff.rs | 883 ++++++++++++++++++++++++-- src/ui/diff_overlay.rs | 350 ++++++---- src/ui/diff_rows.rs | 242 +++++++ 3 files changed, 1301 insertions(+), 174 deletions(-) diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 5bf692a3..209c2991 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -19,15 +19,172 @@ 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, } +/// Which patch to ask git for. +/// +/// The three working-tree variants are the same three questions the SCM panel +/// asks — `Worktree` is what is not staged, `Staged` is what is, and `Head` is +/// both at once, which is what the overlay has always shown. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] +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. + Commit { rev: String }, + /// `base...head`: what `head` added since the two diverged. + Range { base: String, head: String }, +} + +impl DiffSource { + /// 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) + } +} + +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, @@ -91,6 +248,12 @@ pub enum FileStatus { 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)] @@ -126,43 +289,66 @@ pub struct DiffLine { pub text: String, } +/// The whole working tree against `HEAD`, on the panel's budget. pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - let root = git::git(host, cwd, &["rev-parse", "--show-toplevel"])?; - let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); - let branch = git::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), - ); + probe_diff(host, cwd, &DiffRequest::default()) +} + +pub fn probe_diff(host: &dyn Host, root: &Path, req: &DiffRequest<'_>) -> Option { + 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; - 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))) { + // 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, + root: toplevel, + source: req.source.clone(), branch, files, untracked, untracked_total, - read_failed: !matches!(diffed, Ok(Some(0))) || !matches!(listed, Ok(Some(0))), + read_failed: !matches!(diffed, Ok(Some(0))) || !untracked_ok, }) } @@ -177,6 +363,7 @@ pub fn parse_unified(out: &str) -> Vec { #[derive(Default)] pub struct DiffParser { + budget: DiffBudget, files: Vec, old_no: u32, new_no: u32, @@ -184,26 +371,44 @@ pub struct DiffParser { 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.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; + 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; }; @@ -219,6 +424,20 @@ impl DiffParser { file.status = FileStatus::Renamed; return; } + if line.starts_with("copy from ") { + file.status = FileStatus::Copied; + 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; @@ -231,12 +450,16 @@ impl DiffParser { } 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 >= MAX_FILES_WITH_HUNKS) - || self.total_lines >= MAX_TOTAL_LINES + 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; @@ -256,11 +479,8 @@ impl DiffParser { 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, + let Some((kind, text)) = split_body_line(line, self.markers) else { + return; }; match kind { LineKind::Added => file.added += 1, @@ -271,11 +491,11 @@ impl DiffParser { return; } self.file_lines += 1; - if self.file_lines > MAX_LINES_PER_FILE { + if self.file_lines > self.budget.max_lines_per_file { file.truncated = Some(Truncation::PerFile); return; } - if self.total_lines >= MAX_TOTAL_LINES { + if self.total_lines >= self.budget.max_total_lines { file.truncated = Some(Truncation::Budget); return; } @@ -310,8 +530,76 @@ impl DiffParser { } pub fn finish(self) -> Vec { - self.files + 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 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 what happened to the line. +fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, &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; + }; + Some((kind, &line[markers..])) } fn is_hunk_line(line: &str) -> bool { @@ -366,13 +654,34 @@ fn strip_prefix_ab(p: &str) -> String { .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 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)) + 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)] @@ -446,6 +755,176 @@ Binary files a/img.png and b/img.png differ 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 { + rev: "deadbeef".into(), + }, + 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:?}"); + } + } + + #[test] + fn quote_path_is_off_on_every_source() { + // Left on, a non-ASCII path arrives as C octal escapes that nothing + // downstream decodes — `parse_quoted_pair` would hand back the literal + // digits. 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 { rev: "HEAD".into() }, + DiffSource::Range { + base: "a".into(), + head: "b".into(), + }, + ] { + assert_eq!(&argv(source.clone())[..2], ["-c", "core.quotePath=false"]); + } + } + + #[test] + fn octal_escaped_paths_are_what_the_flag_prevents() { + 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_ne!( + escaped[0].path, "中文名.txt", + "the escapes are not decoded here, which is why they must not be produced" + ); + + let raw = parse_unified("diff --git a/中文名.txt b/中文名.txt\n"); + assert_eq!(raw[0].path, "中文名.txt"); + } + + #[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 { rev: "x".into() }.lists_untracked()); + assert!( + !DiffSource::Range { + base: "a".into(), + head: "b".into() + } + .lists_untracked() + ); + } + #[test] fn parses_renames() { let out = "\ @@ -462,6 +941,116 @@ rename to new/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)); + } + + #[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"; @@ -655,6 +1244,200 @@ index 1..2 100644 ); } + /// 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]| { + git::git_output(&dir, args) + .ok() + .filter(|out| out.success()) + .is_some() + }; + if !run(&["init", "-q", "-b", "mainwork", "."]) { + return None; + } + run(&["config", "user.email", "t@tty7.test"]); + run(&["config", "user.name", "tty7 test"]); + 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: 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() { diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index da52eaa7..17bd7aa8 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -9,14 +9,21 @@ use gpui_component::button::Button; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use crate::terminal::git_diff::{ - self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffStats, FileDiff, FileStatus, LineKind, + self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffSource, DiffStats, FileDiff, FileStatus, MAX_RENDERED_FILES, Truncation, }; use crate::ui::app::Tty7App; +use crate::ui::diff_rows::{Side, SplitCell, SplitRow, split_hunk}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; +/// What the right panel's shared probe still asks git for, and therefore what +/// an overlay opened from the panel has to ask for too: the seed below is only +/// sound while the two agree. Both move together when the panel splits into +/// staged and unstaged groups. +const PANEL_DIFF_SOURCE: DiffSource = DiffSource::Head; + pub(crate) enum DiffLoad { Loading, Ready(Arc), @@ -26,11 +33,15 @@ 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, + pub(crate) scroll: gpui::ScrollHandle, } impl Tty7App { @@ -51,6 +62,18 @@ impl Tty7App { focus: Option, window: &mut Window, cx: &mut Context, + ) { + self.open_diff_overlay(host, cwd, PANEL_DIFF_SOURCE, 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| { @@ -59,11 +82,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); @@ -78,8 +104,12 @@ impl Tty7App { } None => {} } + // Skipping the "Reading…" flash is only allowed when the panel's + // snapshot answers the same question this overlay is asking. let seed = match (&self.right_panel.diff_cwd, &self.right_panel.diff) { - (Some(panel_key), Some(Some(snap))) if *panel_key == (host, cwd.clone()) => { + (Some(panel_key), Some(Some(snap))) + if source == PANEL_DIFF_SOURCE && *panel_key == (host, cwd.clone()) => + { DiffLoad::Ready(Arc::clone(snap)) } _ => DiffLoad::Loading, @@ -92,11 +122,13 @@ impl Tty7App { tab.diff_overlay = Some(DiffOverlayState { host_id: host, cwd, + source, focus_handle: focus_handle.clone(), load: seed, loading: false, expanded: HashMap::new(), focus, + scroll: gpui::ScrollHandle::new(), }); window.focus(&focus_handle, cx); self.spawn_diff_probe(cx); @@ -137,12 +169,13 @@ impl Tty7App { return; } let cwd = overlay.cwd.clone(); + let source = overlay.source.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); + self.spawn_diff_probe_for(host, cwd, source, cx); } pub(crate) fn spawn_shared_diff_probe( @@ -151,22 +184,40 @@ impl Tty7App { cwd: PathBuf, cx: &mut Context, ) { - let key = (host.id(), cwd.clone()); + self.spawn_diff_probe_for(host, cwd, PANEL_DIFF_SOURCE, cx) + } + + pub(crate) fn spawn_diff_probe_for( + &mut self, + host: crate::ui::host_ops::SharedHost, + cwd: PathBuf, + source: DiffSource, + cx: &mut Context, + ) { + 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); } }, ); @@ -176,6 +227,7 @@ impl Tty7App { &mut self, host: crate::ui::host_ops::HostId, cwd: &Path, + source: &DiffSource, snap: Option>, cx: &mut Context, ) { @@ -184,7 +236,7 @@ impl Tty7App { 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; }; @@ -195,6 +247,12 @@ impl Tty7App { }; landed = true; } + if *source != PANEL_DIFF_SOURCE { + if landed { + cx.notify(); + } + return; + } let key = (host, cwd.to_path_buf()); if self.right_panel.diff_pending.as_ref() == Some(&key) { self.right_panel.diff_pending = None; @@ -220,6 +278,13 @@ impl Tty7App { if overlay.loading { return; } + // The cached counts come from `git diff --numstat HEAD`, so only a + // HEAD snapshot is comparable to them. A staged or unstaged snapshot + // would differ the moment anything is staged, and re-probe forever; a + // commit or a range cannot go stale at all. + if overlay.source != DiffSource::Head { + return; + } let DiffLoad::Ready(snap) = &overlay.load else { return; }; @@ -250,9 +315,13 @@ impl Tty7App { DiffLoad::Ready(snap) if empty_snapshot(snap) => { self.diff_message(t(L10nKey::DiffWorkingTreeClean), cx) } - DiffLoad::Ready(snap) => { - self.diff_file_list(snap, &overlay.expanded, focused_file(snap, overlay), cx) - } + DiffLoad::Ready(snap) => self.diff_file_list( + snap, + &overlay.expanded, + focused_file(snap, overlay), + &overlay.scroll, + cx, + ), }; let header = self.diff_header(overlay, window, cx); @@ -442,6 +511,7 @@ impl Tty7App { snap: &DiffSnapshot, expanded: &HashMap, focused: Option, + scroll: &gpui::ScrollHandle, cx: &mut Context, ) -> AnyElement { let stats = snap.stats(); @@ -480,13 +550,17 @@ impl Tty7App { if focused.is_none() && !snap.untracked.is_empty() { list = list.child(self.diff_untracked_section(snap, cx)); } - div() - .id("diff-overlay-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child(list) - .into_any_element() + 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_oversized_notice( @@ -527,6 +601,9 @@ impl Tty7App { FileStatus::Modified => ("M", cx.theme().warning), FileStatus::Deleted => ("D", cx.theme().danger), FileStatus::Renamed => ("R", cx.theme().muted_foreground), + FileStatus::Copied => ("C", cx.theme().muted_foreground), + FileStatus::TypeChanged => ("T", cx.theme().warning), + FileStatus::Unmerged => ("U", cx.theme().danger), }; let shown_path = match &file.old_path { Some(old) => format!("{old} → {}", file.path), @@ -863,76 +940,19 @@ 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. `Debug` is what +/// makes the tag unique — it carries the rev of a `Commit` and both ends of a +/// `Range` — and the separator is a byte no path contains. +fn probe_key( + host: crate::ui::host_ops::HostId, + cwd: &Path, + source: &DiffSource, +) -> (crate::ui::host_ops::HostId, PathBuf) { + let mut tagged = std::ffi::OsString::from(format!("{source:?}\u{1}")); + tagged.push(cwd.as_os_str()); + (host, PathBuf::from(tagged)) } #[cfg(test)] @@ -951,40 +971,22 @@ 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)); - } - - #[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 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 { rev: "a".into() }), + probe_key(host, cwd, &DiffSource::Commit { rev: "b".into() }), + "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) + ); } fn small_file(path: &str, added: u32) -> FileDiff { @@ -1376,3 +1378,103 @@ 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" + ); + } + + /// 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 index f93f9f10..8a10215b 100644 --- a/src/ui/diff_rows.rs +++ b/src/ui/diff_rows.rs @@ -3,3 +3,245 @@ //! 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 +} + +// Read by the unified renderer, which lands with the view toggle. The rows +// themselves belong here now so that both shapes of the same hunk are built — +// and tested — in one place rather than growing a second copy later. +#[allow(dead_code)] +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. +#[allow(dead_code)] +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" + ); + } +} From 80e8e09548054ac45c36c7c3419016e5a5d42512 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:51:06 +0800 Subject: [PATCH 11/36] feat(scm): turn the Changes tab into Source Control and wire the surface up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames `RightPanelTab::Changes` to `Scm` in place. `#[serde(rename = "changes")]` works in both directions, so what lands on disk is unchanged and a build from before this commit reads the config back without kicking anyone off the panel they left open — a fourth variant could not do that, and 260px has no room for a fourth tab tile anyway. The action name `ShowRightPanelChanges` stays put because `Config::keybindings` is keyed by it, and every existing custom binding would otherwise be orphaned. The panel body moves to `src/ui/scm/panel.rs` byte for byte; it still renders the flat `git diff HEAD` list. Alongside it the module gets the pieces the rest of the feature is built from: the shared status glyph and colour tables (one definition instead of three that drift), the path and timestamp helpers a 260px column needs, and the panel's state types. Also wires the whole surface: fourteen actions, their key bindings, ten palette commands in a `Git` group of their own, and the translations. `ScmCommit` takes `secondary-enter`, which macOS already gives `ToggleFullscreen`; the two coexist because the commit binding is scoped to the commit box, and `every_default_chord_is_claimed_by_exactly_one_action` now checks uniqueness per context instead of globally, which is the actual invariant gpui enforces. `Config` gains `diff_view` and `scm_graph_expanded`. Both default to what happens today. Two new icons. `git-sync.svg` is 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. Adds `every_action_has_a_binding_arm`, which walks every action in `default_bindings` rather than only the ones shipping a default keystroke. The gap it closes is an action listed in Settings with no `make_binding` arm behind it: the user assigns a key and the key silently does nothing. --- assets/icons/git-commit.svg | 1 + assets/icons/git-sync.svg | 1 + crates/tty7-core/src/core/config.rs | 31 ++- src/core/actions.rs | 17 ++ src/ui/app.rs | 68 ++++++- src/ui/assets.rs | 21 ++ src/ui/i18n/en.rs | 74 ++++++- src/ui/i18n/ja.rs | 72 ++++++- src/ui/i18n/mod.rs | 135 +++++++++++++ src/ui/i18n/zh.rs | 70 ++++++- src/ui/keymap.rs | 74 ++++++- src/ui/palette.rs | 123 +++++++++++- src/ui/right_panel.rs | 216 +------------------- src/ui/scm/actions.rs | 83 ++++++++ src/ui/scm/mod.rs | 17 ++ src/ui/scm/panel.rs | 298 ++++++++++++++++++++++++++++ src/ui/scm/path.rs | 180 +++++++++++++++++ src/ui/scm/state.rs | 157 +++++++++++++++ src/ui/scm/status.rs | 128 ++++++++++++ src/ui/tab_strip.rs | 4 +- 20 files changed, 1549 insertions(+), 221 deletions(-) create mode 100644 assets/icons/git-commit.svg create mode 100644 assets/icons/git-sync.svg create mode 100644 src/ui/scm/actions.rs create mode 100644 src/ui/scm/panel.rs create mode 100644 src/ui/scm/path.rs create mode 100644 src/ui/scm/state.rs create mode 100644 src/ui/scm/status.rs 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/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 8c2f2156..5ddf4a1f 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -155,6 +155,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")] @@ -453,6 +462,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, @@ -731,10 +742,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/src/core/actions.rs b/src/core/actions.rs index 25c886ea..74fdeca1 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/ui/app.rs b/src/ui/app.rs index bf5c4880..5993fe73 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -34,6 +34,7 @@ use crate::ui::palette::{ }; use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot}; use crate::ui::presets::Fill; +use crate::ui::scm::ScmIntent; use crate::ui::settings::{ Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action, }; @@ -387,6 +388,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: @@ -620,6 +622,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) @@ -746,6 +749,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, @@ -3503,6 +3513,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), } @@ -5490,11 +5514,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/i18n/en.rs b/src/ui/i18n/en.rs index 09656160..7d0a1768 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -779,7 +779,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 => { @@ -807,6 +808,55 @@ 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 => "Message", + 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::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 => "Graph", + L10nKey::ScmGraphLoadMore => "Load more", + L10nKey::ScmGraphFilterPlaceholder => "Filter commits…", + L10nKey::ScmGraphAllBranches => "All Branches", + L10nKey::ScmGraphEmpty => "No commits yet", + 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::WindowStop => "Stop", L10nKey::WindowDelete => "Delete", L10nKey::WindowThisWorkspace => "this workspace", @@ -854,6 +904,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 => "Couldn't reach {machine}", L10nKey::WorktreePromptNeedsName => "The worktree needs a name", @@ -1012,6 +1064,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", @@ -1067,6 +1120,21 @@ 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::CmdClearScrollback => "Clear Scrollback", L10nKey::CmdFindInTerminal => "Find in Terminal…", L10nKey::CmdFindNext => "Find Next", @@ -1199,6 +1267,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "… and {count} more changed files — run `git diff` to see them." } L10nKey::PanelUntracked => "{count} untracked", + L10nKey::ScmFilesChanged => "{count} files changed", L10nKey::AppMenuAbout => "About tty7", L10nKey::AppMenuCheckForUpdates => "Check for Updates…", L10nKey::AppMenuSettings => "Settings…", @@ -1301,6 +1370,9 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsOfflineMachines, "other") => { "{count} more saved machines are not connected — open a workspace on one to install its hooks there." } + (L10nKey::ScmFilesChanged, "zero") => "No files changed", + (L10nKey::ScmFilesChanged, "one") => "1 file changed", + (L10nKey::ScmFilesChanged, "other") => "{count} files changed", (L10nKey::PanelUntracked, "zero") => "0 untracked", (L10nKey::PanelUntracked, "one") => "1 untracked", (L10nKey::PanelUntracked, "other") => "{count} untracked", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 07448ed3..e0c3518e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -825,7 +825,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 => { @@ -857,6 +858,55 @@ 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::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 => "detached", + 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::ScmCommitDetailTitle => "コミット", + L10nKey::ScmCopyCommitSha => "コミット SHA をコピー", + L10nKey::ScmCherryPick => "チェリーピック", + L10nKey::ScmRevertCommit => "コミットを取り消す", + L10nKey::ScmResetToCommit => "このコミットにリセット", + L10nKey::ScmRefresh => "更新", + L10nKey::ScmBackToChanges => "戻る", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "削除", L10nKey::WindowThisWorkspace => "このワークスペース", @@ -902,6 +952,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 => "ワークツリーには名前が必要です", @@ -1047,6 +1099,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 => "エージェント", @@ -1102,6 +1155,19 @@ 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::CmdClearScrollback => "スクロールバックをクリア", L10nKey::CmdFindInTerminal => "ターミナル内を検索…", L10nKey::CmdFindNext => "次を検索", @@ -1244,6 +1310,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } L10nKey::PanelUntracked => "未追跡 {count}", + L10nKey::ScmFilesChanged => "{count} 個のファイルが変更されました", L10nKey::AppMenuAbout => "tty7 について", L10nKey::AppMenuCheckForUpdates => "アップデートを確認…", L10nKey::AppMenuSettings => "設定…", @@ -1344,6 +1411,9 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsOfflineMachines, "other") => { "未接続の保存済みマシンがさらに {count} 台あります — いずれかでワークスペースを開くと、そこにフックをインストールできます" } + (L10nKey::ScmFilesChanged, "zero") => "変更されたファイルはありません", + (L10nKey::ScmFilesChanged, "one") => "1 個のファイルが変更されました", + (L10nKey::ScmFilesChanged, "other") => "{count} 個のファイルが変更されました", (L10nKey::PanelUntracked, "zero") => "未追跡 0", (L10nKey::PanelUntracked, "one") => "未追跡 1", (L10nKey::PanelUntracked, "other") => "未追跡 {count}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 3dd997b2..484a737d 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -601,7 +601,11 @@ pub enum L10nKey { EditorFileTooLarge, EditorBinaryFile, PanelInfoTitle, + /// The tab tile's tooltip. PanelChangesTitle, + /// The panel's own header. Separate from the tooltip so the two can differ + /// in length without one of them reading wrong. + PanelScmTitle, PanelFilesTitle, PanelNoSession, PanelNoSessionHint, @@ -629,6 +633,57 @@ pub enum L10nKey { PanelAgentDone, PanelRevealInFinder, PanelOpenFolder, + ScmGroupMerge, + ScmGroupStaged, + ScmGroupChanges, + ScmGroupUntracked, + ScmCommitPlaceholder, + ScmCommitButton, + ScmCommitAllButton, + ScmCommitAmendButton, + ScmCommitAndPush, + ScmCommitAndSync, + ScmAmendLastCommit, + ScmCommitStaged, + ScmStashAll, + ScmNothingToCommit, + ScmStage, + ScmStageAll, + ScmUnstage, + ScmUnstageAll, + ScmDiscard, + ScmDiscardAll, + ScmDiscardConfirm, + ScmOpenConflict, + ScmMarkResolved, + /// Tooltip on the greyed-out buttons of a row whose path is not valid + /// UTF-8: the control protocol carries argv as strings, so such a path + /// cannot be handed to git at all. Read-only is the designed fallback. + ScmUnrepresentablePath, + ScmPublishBranch, + ScmDetached, + ScmAmendBadge, + ScmSync, + ScmPush, + ScmPull, + ScmFetch, + ScmCheckoutBranch, + ScmCreateBranch, + ScmSearchBranches, + ScmStashAndSwitch, + ScmGraphTitle, + ScmGraphLoadMore, + ScmGraphFilterPlaceholder, + ScmGraphAllBranches, + ScmGraphEmpty, + ScmCommitDetailTitle, + ScmCopyCommitSha, + ScmCherryPick, + ScmRevertCommit, + ScmResetToCommit, + ScmRefresh, + ScmBackToChanges, + ScmFilesChanged, WindowStop, WindowDelete, WindowThisWorkspace, @@ -656,6 +711,8 @@ pub enum L10nKey { DiffBudget, DiffPerFileCap, DiffUntrackedSummary, + DiffViewSplit, + DiffViewUnified, PendingConnecting, PendingUnreachable, WorktreePromptNeedsName, @@ -833,6 +890,7 @@ pub enum L10nKey { CmdGroupTabsPanes, CmdGroupWorkspaces, CmdGroupView, + CmdGroupGit, CmdGroupTerminal, CmdGroupSsh, CmdGroupAgents, @@ -888,6 +946,19 @@ pub enum L10nKey { CmdChangeTheme, CmdResetFontSize, CmdEnterFullScreen, + CmdToggleDiffViewMode, + CmdGitCommit, + CmdGitStageAll, + CmdGitUnstageAll, + CmdGitDiscardAll, + CmdGitDiscardAllSubtitle, + CmdGitCheckoutTo, + CmdGitCreateBranch, + CmdGitSync, + CmdGitSyncSubtitle, + CmdGitPush, + CmdGitPull, + CmdGitFetch, CmdClearScrollback, CmdFindInTerminal, CmdFindNext, @@ -994,6 +1065,69 @@ pub enum L10nKey { SftpErrorInvalidOctalMode, } +/// 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::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::ScmStage, + L10nKey::ScmStageAll, + L10nKey::ScmUnstage, + L10nKey::ScmUnstageAll, + L10nKey::ScmDiscard, + L10nKey::ScmDiscardAll, + L10nKey::ScmDiscardConfirm, + L10nKey::ScmOpenConflict, + L10nKey::ScmMarkResolved, + L10nKey::ScmUnrepresentablePath, + 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::ScmCommitDetailTitle, + L10nKey::ScmCopyCommitSha, + L10nKey::ScmCherryPick, + L10nKey::ScmRevertCommit, + L10nKey::ScmResetToCommit, + L10nKey::ScmRefresh, + L10nKey::ScmBackToChanges, + L10nKey::ScmFilesChanged, + L10nKey::DiffViewSplit, + L10nKey::DiffViewUnified, +]; + pub fn set_locale(gui_language: &str) { let index = SUPPORTED_LANGUAGES .iter() @@ -2058,6 +2192,7 @@ mod tests { L10nKey::SettingsOfflineMachines, L10nKey::PanelUntracked, L10nKey::PanelMoreChangedFiles, + L10nKey::ScmFilesChanged, L10nKey::WindowStopShells, L10nKey::WindowDeleteShells, L10nKey::DiffChangedFiles, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 9dfaae89..ac50df6b 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -756,7 +756,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、目录和进程。", @@ -782,6 +783,53 @@ 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::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::ScmCommitDetailTitle => "提交", + L10nKey::ScmCopyCommitSha => "复制提交 SHA", + L10nKey::ScmCherryPick => "拣选提交", + L10nKey::ScmRevertCommit => "还原提交", + L10nKey::ScmResetToCommit => "重置到该提交", + L10nKey::ScmRefresh => "刷新", + L10nKey::ScmBackToChanges => "返回", L10nKey::WindowStop => "停止", L10nKey::WindowDelete => "删除", L10nKey::WindowThisWorkspace => "此工作区", @@ -817,6 +865,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 需要一个名称", @@ -961,6 +1011,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", @@ -1016,6 +1067,19 @@ pub fn translate_zh(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::CmdClearScrollback => "清除 scrollback", L10nKey::CmdFindInTerminal => "在终端中查找…", L10nKey::CmdFindNext => "查找下一个", @@ -1144,6 +1208,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", L10nKey::PanelUntracked => "{count} 个未跟踪文件", + L10nKey::ScmFilesChanged => "{count} 个文件改动", L10nKey::AppMenuAbout => "关于 tty7", L10nKey::AppMenuCheckForUpdates => "检查更新…", L10nKey::AppMenuSettings => "设置…", @@ -1242,6 +1307,9 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsOfflineMachines, "other") => { "还有 {count} 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装 hook。" } + (L10nKey::ScmFilesChanged, "zero") => "没有文件改动", + (L10nKey::ScmFilesChanged, "one") => "1 个文件改动", + (L10nKey::ScmFilesChanged, "other") => "{count} 个文件改动", (L10nKey::PanelUntracked, "zero") => "0 个未跟踪文件", (L10nKey::PanelUntracked, "one") => "1 个未跟踪文件", (L10nKey::PanelUntracked, "other") => "{count} 个未跟踪文件", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 55b2835f..76cb394a 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -309,6 +309,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"), @@ -521,6 +538,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, } } @@ -593,6 +611,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)), @@ -682,6 +714,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") { @@ -929,15 +987,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/palette.rs b/src/ui/palette.rs index aa237815..a80bd982 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 { - const ORDER: [CommandGroup; 7] = [ + 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), @@ -424,7 +469,7 @@ impl Command { ), Command::new( t(L10nKey::CmdRightPanelChanges), - ShowRightPanel(RightPanelTab::Changes), + ShowRightPanel(RightPanelTab::Scm), ), Command::new( t(L10nKey::CmdRightPanelFiles), @@ -433,6 +478,24 @@ impl Command { Command::new(t(L10nKey::CmdChangeTheme), OpenThemePicker), Command::new(t(L10nKey::CmdResetFontSize), ResetFontSize), Command::new(t(L10nKey::CmdEnterFullScreen), ToggleFullscreen), + Command::new(t(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::new(t(L10nKey::CmdGitCommit), ScmCommit), + Command::new(t(L10nKey::CmdGitStageAll), ScmStageAll), + Command::new(t(L10nKey::CmdGitUnstageAll), ScmUnstageAll), + Command::new(t(L10nKey::CmdGitDiscardAll), ScmDiscardAll) + .with_subtitle(t(L10nKey::CmdGitDiscardAllSubtitle)), + Command::new(t(L10nKey::CmdGitCheckoutTo), OpenBranchPicker), + Command::new(t(L10nKey::CmdGitCreateBranch), ScmCreateBranch), + Command::new(t(L10nKey::CmdGitSync), ScmSync) + .with_subtitle(t(L10nKey::CmdGitSyncSubtitle)), + Command::new(t(L10nKey::CmdGitPush), ScmPush), + Command::new(t(L10nKey::CmdGitPull), ScmPull), + Command::new(t(L10nKey::CmdGitFetch), ScmFetch), ]; let terminal = [ @@ -482,6 +545,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); @@ -1204,3 +1268,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/right_panel.rs b/src/ui/right_panel.rs index 78fef564..7427aa4d 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -9,12 +9,12 @@ 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::terminal::git_diff::DiffSnapshot; 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.; @@ -84,7 +84,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); @@ -316,7 +316,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() @@ -336,7 +336,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)) @@ -704,207 +709,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(px(12.)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(path), - ) - .when(added > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .font_family(mono.clone()) - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - }) - .when(removed > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .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(px(11.5)) - .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(px(11.5)) - .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()); diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs new file mode 100644 index 00000000..86087de1 --- /dev/null +++ b/src/ui/scm/actions.rs @@ -0,0 +1,83 @@ +//! Where the source control actions and palette commands land. +//! +//! Two of them are finished here because they are pure view state and have +//! nothing to wait for. The rest funnel through one `ScmIntent` match so the +//! wiring — action, key binding, palette entry, menu item — can be verified +//! now, and each arm gets its body filled in by the step that owns it. + +use gpui::Context; + +use crate::core::config::DiffViewMode; +use crate::ui::app::Tty7App; + +/// One entry point for every source control verb. +/// +/// A single enum rather than fourteen methods: the actions, the palette and +/// (later) 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, + StageAll, + UnstageAll, + DiscardAll, + Refresh, + Sync, + Push, + Pull, + Fetch, + CheckoutBranch, + CreateBranch, +} + +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(); + } + + pub(crate) fn run_scm_action( + &mut self, + intent: ScmIntent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + match intent { + // Refresh is the one verb the panel can already answer: the flat + // diff probe behind the old Changes tab is exactly what it means. + ScmIntent::Refresh => self.right_panel_refresh_changes(cx), + // Staging, discarding and committing need `core::git::ops`, which + // arrives with the row buttons and the commit box. + ScmIntent::StageAll + | ScmIntent::UnstageAll + | ScmIntent::DiscardAll + | ScmIntent::Commit + | ScmIntent::CommitAmend => {} + // The network verbs and the branch switcher come with the + // repository header row. + ScmIntent::Sync + | ScmIntent::Push + | ScmIntent::Pull + | ScmIntent::Fetch + | ScmIntent::CheckoutBranch + | ScmIntent::CreateBranch => {} + } + } +} diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index f238d5cd..24c60ed7 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -3,3 +3,20 @@ //! 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 scaffolding lands one step ahead of the code that consumes it: the panel +// still renders the old flat list, so the status helpers, the path helpers and +// most of `ScmPanelState` have no caller yet. Each `allow` comes off as its +// module gets wired up rather than being left as a blanket at the top. +pub(crate) mod actions; +#[allow(dead_code)] +pub(crate) mod panel; +#[allow(dead_code)] +pub(crate) mod path; +#[allow(dead_code)] +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..801aae5c --- /dev/null +++ b/src/ui/scm/panel.rs @@ -0,0 +1,298 @@ +//! The source control panel body. +//! +//! Lifted out of `right_panel.rs` unchanged — this is still the flat +//! `git diff HEAD` list the Changes tab always showed. The groups, the real +//! status letters and the commit box land on top of it in later steps. + +use gpui::{AnyElement, Context, Window, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, h_flex, v_flex}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::terminal::git_diff::MAX_RENDERED_FILES; +use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::right_panel::git_badge; + +impl Tty7App { + pub(crate) fn render_panel_scm( + &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::PanelScmTitle), 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::PanelScmTitle), 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(px(12.)) + .font_family(mono.clone()) + .text_color(cx.theme().foreground) + .child(path), + ) + .when(added > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().success) + .child(format!("+{added}")), + ) + }) + .when(removed > 0, |this| { + this.child( + div() + .flex_none() + .text_size(px(11.)) + .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(px(11.5)) + .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(px(11.5)) + .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); + } + } +} + +#[cfg(test)] +mod tests { + use crate::core::config::{CoreConfig, DiffViewMode, RightPanelTab}; + use crate::ui::app::test_window::harness; + use gpui::TestAppContext; + + 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 + ); + } +} diff --git a/src/ui/scm/path.rs b/src/ui/scm/path.rs new file mode 100644 index 00000000..4f730595 --- /dev/null +++ b/src/ui/scm/path.rs @@ -0,0 +1,180 @@ +//! 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. +/// +/// `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 { + // 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); + match delta { + d if d < MINUTE => "now".to_string(), + d if d < HOUR => format!("{}m", d / MINUTE), + d if d < DAY => format!("{}h", d / HOUR), + d if d < MONTH => format!("{}d", d / DAY), + d if d < YEAR => format!("{}mo", d / MONTH), + d => format!("{}y", 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() { + 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() { + let now = 1_800_000_000i64; + assert_eq!(relative_time(now, now + DAY), "now"); + } +} diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs new file mode 100644 index 00000000..55b2405b --- /dev/null +++ b/src/ui/scm/state.rs @@ -0,0 +1,157 @@ +//! 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 gpui::Entity; +use gpui_component::input::InputState; + +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, + /// 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>, + /// 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. Absent means open, so a group that has + /// never been touched renders expanded. + pub(crate) collapsed: HashSet, + 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()) + } + + pub(crate) fn draft(&self, repo: &RepoKey) -> &str { + self.drafts.get(repo).map(String::as_str).unwrap_or("") + } +} + +#[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, + /// Filter box. Like `commit_input`, created on first render. + pub(crate) search: Option>, + /// `refs/heads/...` the graph is restricted to; empty means all refs. + pub(crate) branch_filter: Option, + /// 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. +pub(crate) struct CommitDetailView { + pub(crate) repo: RepoKey, + pub(crate) oid: String, + pub(crate) loading: bool, +} + +#[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/tab_strip.rs b/src/ui/tab_strip.rs index 3b2685e6..f033823c 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -404,7 +404,7 @@ impl Tty7App { L10nKey::PanelInfoTitle, ), ( - RightPanelTab::Changes, + RightPanelTab::Scm, Icon::empty().path("icons/git-branch.svg"), L10nKey::PanelChangesTitle, ), @@ -427,7 +427,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)), From ac5983056c54ee77d5a9dd7fb62055124ed1ca99 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:55:39 +0800 Subject: [PATCH 12/36] style(git): collapse the ahead/behind fallback into one condition --- crates/tty7-core/src/core/git/status.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index 2969234b..0e84d4be 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -822,10 +822,10 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option { return None; } let mut parsed = parse_porcelain_v2(&out.stdout); - if parsed.ahead_behind.is_none() { - if let Some(upstream) = parsed.upstream.clone() { - parsed.ahead_behind = rev_list_ahead_behind(host, cwd, &upstream); - } + 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(); From 75c3af104712d04708ffbe8326ffb98e6671e13a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:51 +0800 Subject: [PATCH 13/36] feat(git): add the source control data pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One cache of what each repository looks like, one way to change it, one way to say that is now stale. The panel, the file tree's decorations and the `.git` watcher all build on this, and they are being written in parallel — landing the contract first is what keeps them from each inventing their own. Kept apart from git_status deliberately. That cache answers a cheap question for a tab badge on every cwd change and every command boundary, for every pane. This one runs `status --porcelain=v2 -uall`, which is seconds on a large repository, and only while something is looking. Folding them would put the expensive probe on the cheap trigger. Invalidation is by epoch, not by key: working out which entries a `git add` touched is a losing game, and a counter per repository cannot miss one. run_git_op also caps concurrent network operations per host at two. The far side serves every request from one worker pool and keepalive's Ping queues behind the rest of it, so enough concurrent pushes and the link is declared dead — the client is the only place that can hold the number down. --- src/terminal/git_data.rs | 346 +++++++++++++++++++++++++++++++++++++++ src/terminal/mod.rs | 1 + 2 files changed, 347 insertions(+) create mode 100644 src/terminal/git_data.rs diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs new file mode 100644 index 00000000..11f3aba0 --- /dev/null +++ b/src/terminal/git_data.rs @@ -0,0 +1,346 @@ +//! 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. + +// This module is the contract the panel, the file tree and the `.git` watcher +// are built against, and it landed before any of them. Without the allow every +// item here reports unused and the real dead code elsewhere gets lost in the +// noise. Take it off once the panel calls `scm_refresh` — by then anything +// still unused genuinely is. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gpui::{Context, Window}; + +use crate::core::git::ops::{GitOp, GitOpError, GitOpErrorKind, GitOpOutcome, run_op}; +use crate::core::git::status::{StatusIndex, WorkingTreeStatus, probe_status}; +use crate::ui::app::Tty7App; +use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost}; + +/// How many network operations one host 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. +pub const MAX_CONCURRENT_NETWORK_OPS: usize = 2; + +#[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, + probes: InFlight<(HostId, PathBuf)>, + network: ByHost, +} + +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() + } + + 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. + pub fn is_stale(&self, host: HostId, root: &Path) -> bool { + match self.read_at.get(host, root) { + Some(read) => *read < self.epoch(host, root), + None => true, + } + } + + /// Mark a repository changed. Every write, every `.git` watcher event and + /// every command boundary lands here; readers reprobe on their next look. + 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.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.network.clear_host(host); + } + + fn network_slots(&self, host: HostId, root: &Path) -> usize { + self.network.get(host, root).copied().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 probe_root = root.clone(); + HostOps::run_detached( + host.clone(), + cx, + move |h| { + let status = probe_status(h, &probe_root)?; + let index = StatusIndex::build(&status); + Some((Arc::new(status), Arc::new(index))) + }, + move |cx, result| { + let data = cx.default_global::(); + // The return says whether the epoch moved while this was in + // flight. Nothing to do with it: `read_at` records the epoch + // the read *started* at, so a bump has already left this + // result behind and the next look reprobes on its own. + data.probes.finish(&key); + if let Some((status, index)) = result { + data.status.insert(id, root.clone(), status); + data.index.insert(id, root.clone(), index); + data.read_at.insert(id, root.clone(), at); + } + }, + ); + } + + /// 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, + window: &Window, + cx: &mut Context, + ) { + let Some(status) = status_of(cx, host.id(), &root) else { + return; + }; + let head = status.head.clone(); + let id = host.id(); + let network = op.is_network(); + + if network { + let data = cx.default_global::(); + if data.network_slots(id, &root) >= MAX_CONCURRENT_NETWORK_OPS { + return; + } + let next = data.network_slots(id, &root) + 1; + data.network.insert(id, root.clone(), next); + } + + let op_root = root.clone(); + HostOps::run_in( + host.clone(), + window, + cx, + move |h| run_op(h, &op_root, &op, &head), + move |app, result, window, cx| { + if network { + let data = cx.default_global::(); + let left = data.network_slots(id, &root).saturating_sub(1); + data.network.insert(id, root.clone(), left); + } + cx.default_global::().bump(id, &root); + app.on_git_op_done(host, root, result, window, cx); + }, + ); + } + + fn on_git_op_done( + &mut self, + host: SharedHost, + root: PathBuf, + result: Result, + window: &mut Window, + cx: &mut Context, + ) { + match result { + Ok(_) => { + self.scm_refresh(host, root, cx); + cx.notify(); + } + Err(err) => { + self.report_git_op_error(&err, window, cx); + self.scm_refresh(host, root, cx); + } + } + } + + /// 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), + ); + } + } +} + +/// 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 ''"); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 6368d5b1..cabc378d 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; From f6e8569920064aa62c82bccdde209342f9d70018 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:16:19 +0800 Subject: [PATCH 14/36] feat(scm): decorate the file tree with git status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree had no git in it at all. Now every row asks the repository's `StatusIndex` what it is: the name takes the status colour, and a letter lands in a 14px trailing cell drawn by the same `git_badge` the panel and the diff cards use, so a status letter has exactly one look everywhere. Directories roll up to two states and no letter — a folder is not "M", but a collapsed folder still has to say whether there is work under it, which is the whole reason the decoration earns its place. The unsaved-buffer dot keeps its own column and its own shape. It is not a git indicator and never was; round-and-warning next to the letter cell is what keeps the two from being read as one. Ignored rows are left exactly as they were. Italic and dim already says everything, and a tree full of `!` is noise. Cost per row is one hash probe and no allocation: the `Arc` and the repo roots are taken once outside the loop, and the key is borrowed straight out of the path on any platform whose separator is already `/`. --- src/ui/file_tree.rs | 466 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 462 insertions(+), 4 deletions(-) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 3e718bfa..e2094794 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1,12 +1,17 @@ +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::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, @@ -1176,6 +1181,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() @@ -1197,10 +1203,10 @@ impl Tty7App { .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { this.file_tree_key_down(ev, window, cx); })) - .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) + })); crate::ui::scrollbar::with_vertical_scrollbar( "right-panel-tree-scrollbar", column, @@ -1208,9 +1214,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 { @@ -1252,6 +1285,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() @@ -1274,6 +1314,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() @@ -1283,6 +1327,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({ @@ -1549,6 +1600,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 @@ -1712,6 +1874,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"); @@ -2042,6 +2408,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(); @@ -2069,6 +2479,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(); From b92d9afa03f1ff39d7f70ecdb9320bc855f0b93b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:19:02 +0800 Subject: [PATCH 15/36] feat(scm): group the panel by index and working tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Changes tab ran one `git diff HEAD`, so it could not tell a staged change from an unstaged one and gave every row the letter `M`. The panel now renders `WorkingTreeStatus`, which reports both halves of `XY` separately, in the four sections git itself talks about: Merge Changes, Staged Changes, Changes, Untracked. A row wears the letter of the half its group is about, so a file added to the index and then edited again reads `A` under Staged and `M` under Changes — and clicking it opens the matching patch, `--cached` for a staged row and the working tree for the rest. The group chevron sits in a box exactly as wide as `git_badge`, so the fold arrows and the status letters below them form one column. Two things guard the render loop. The pane's directory is turned into the repository root before anything is cached or run, because porcelain pathspecs are relative to the root and a write from a subdirectory would name the wrong files; and the `ScmData` watcher compares before it notifies, since `scm_refresh` reaches for the global through `default_global` from inside `render` and an unconditional notify would ask for a frame from inside a frame forever. --- src/ui/i18n/en.rs | 15 + src/ui/i18n/ja.rs | 17 + src/ui/i18n/mod.rs | 29 ++ src/ui/i18n/zh.rs | 13 + src/ui/scm/mod.rs | 8 +- src/ui/scm/panel.rs | 886 ++++++++++++++++++++++++++++++++++---------- src/ui/scm/state.rs | 41 +- 7 files changed, 814 insertions(+), 195 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 7d0a1768..6e1c8200 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -857,6 +857,21 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmResetToCommit => "Reset to Commit", L10nKey::ScmRefresh => "Refresh", L10nKey::ScmBackToChanges => "Back", + 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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index e0c3518e..4a782126 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -907,6 +907,23 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "このコミットにリセット", L10nKey::ScmRefresh => "更新", L10nKey::ScmBackToChanges => "戻る", + 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 => "このワークスペース", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 484a737d..81cf061a 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -683,6 +683,24 @@ pub enum L10nKey { ScmResetToCommit, ScmRefresh, ScmBackToChanges, + /// Shown when the working tree has more changes than the status parser + /// keeps. The list is still useful; the count at the top would otherwise + /// be a lie. + ScmTooManyChanges, + ScmOpenChanges, + ScmDiscardAllConfirm, + ScmAmendConfirm, + /// Which sequencer operation is parked in the repository. Deliberately no + /// separate wording for an interactive rebase: modern git writes + /// `rebase-merge/interactive` for every rebase, so the UI would be + /// guessing — and `git status` does not draw the distinction either. + ScmOpMerge, + ScmOpRebase, + ScmOpCherryPick, + ScmOpRevert, + ScmOpBisect, + ScmOpAm, + ScmSwitchRepository, ScmFilesChanged, WindowStop, WindowDelete, @@ -1123,6 +1141,17 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmResetToCommit, L10nKey::ScmRefresh, L10nKey::ScmBackToChanges, + L10nKey::ScmTooManyChanges, + L10nKey::ScmOpenChanges, + L10nKey::ScmDiscardAllConfirm, + L10nKey::ScmAmendConfirm, + L10nKey::ScmOpMerge, + L10nKey::ScmOpRebase, + L10nKey::ScmOpCherryPick, + L10nKey::ScmOpRevert, + L10nKey::ScmOpBisect, + L10nKey::ScmOpAm, + L10nKey::ScmSwitchRepository, L10nKey::ScmFilesChanged, L10nKey::DiffViewSplit, L10nKey::DiffViewUnified, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index ac50df6b..e23182a7 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -830,6 +830,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "重置到该提交", L10nKey::ScmRefresh => "刷新", L10nKey::ScmBackToChanges => "返回", + 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 => "此工作区", diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index 24c60ed7..fa2944c1 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,12 +4,10 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// The scaffolding lands one step ahead of the code that consumes it: the panel -// still renders the old flat list, so the status helpers, the path helpers and -// most of `ScmPanelState` have no caller yet. Each `allow` comes off as its -// module gets wired up rather than being left as a blanket at the top. +// What is left unused is what the graph and the commit detail view will call: +// `relative_time` has no row to date yet, and `status_rank` is the file tree's +// to use. Both allows come off with the step that wires them up. pub(crate) mod actions; -#[allow(dead_code)] pub(crate) mod panel; #[allow(dead_code)] pub(crate) mod path; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 801aae5c..2634e228 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -1,18 +1,64 @@ -//! The source control panel body. +//! The source control panel body: four groups of file rows over the working +//! tree status, in the order git itself talks about them. //! -//! Lifted out of `right_panel.rs` unchanged — this is still the flat -//! `git diff HEAD` list the Changes tab always showed. The groups, the real -//! status letters and the commit box land on top of it in later steps. +//! 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 gpui::{AnyElement, Context, Window, div, prelude::*, px}; -use gpui_component::{ActiveTheme as _, h_flex, v_flex}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant}; -use crate::terminal::git_diff::MAX_RENDERED_FILES; +use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex, v_flex}; + +use tty7_core::core::git::diff::MAX_RENDERED_FILES; +use tty7_core::core::git::status::{ChangeCode, DecoStatus, StatusEntry, WorkingTreeStatus}; + +use crate::terminal::git_data::status_of; +use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; -use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::host_ops::{HostId, SharedHost}; +use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::git_badge; +use crate::ui::scm::path::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. +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. +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. +const ROW_INSET: f32 = 4.; + +/// 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; + +/// 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); + +/// 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( @@ -20,212 +66,652 @@ impl Tty7App { 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)) - }); + self.scm_watch_status(cx); - let Some((host, cwd)) = target else { + let Some((host, cwd)) = self.scm_pane_target(window, cx) else { let title = self.panel_title(t(L10nKey::PanelScmTitle), 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::PanelScmTitle), 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)), + let body = self.panel_empty( + t(L10nKey::PanelNoWorkingDirectory), + Some(t(L10nKey::PanelNoWorkingDirectoryHint)), 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(px(12.)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(path), - ) - .when(added > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .font_family(mono.clone()) - .text_color(cx.theme().success) - .child(format!("+{added}")), - ) - }) - .when(removed > 0, |this| { - this.child( - div() - .flex_none() - .text_size(px(11.)) - .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(px(11.5)) - .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(px(11.5)) - .text_color(cx.theme().muted_foreground) - .child(t_plural(L10nKey::PanelUntracked, untracked, &[])), - ), - ); - } - list.into_any_element() + ); + 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.panel_scroll(inner, title) + + self.scm_probe(&host, &root, cx); + let Some(status) = self.scm_seen_status(host.id(), &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); + }; + + self.scm.repo = Some(RepoKey { + host: host.id(), + root: root.clone(), + }); + + let count = (status.total_entries > 0).then(|| status.total_entries.to_string()); + let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); + + if status.is_clean() { + let body = self.panel_empty( + t(L10nKey::PanelNoChanges), + Some(t(L10nKey::PanelNoChangesHint)), + cx, + ); + return self.scm_shell(title, body); + } + + let body = self.scm_groups(&host, &root, &status, cx); + self.scm_shell(title, body) } - fn spawn_right_panel_diff( - &mut self, - host: crate::ui::host_ops::SharedHost, - cwd: PathBuf, + /// 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 { + 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) + .child(crate::ui::scrollbar::with_vertical_scrollbar( + "panel-scm-scrollbar", + scroller, + &self.scm.scroll, + )) + .into_any_element() + } + + /// The host and directory the panel is looking at. + fn scm_pane_target( + &self, + window: &mut Window, 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); + ) -> 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)) } - pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context) { - if self.right_panel.diff_pending.is_some() { + /// 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. + /// + /// The cheap repository/not-a-repository answer comes from the cache the + /// tab badge already fills in, so a directory that is not a repository + /// never reaches `git status` from here at all. + 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()); + if let Some(root) = self.scm.roots.get(&key) { + return RepoLookup::Root(root.clone()); + } + match cx + .try_global::() + .and_then(|cache| cache.known_repo_for(id, cwd)) + { + None => RepoLookup::Pending, + Some(None) => RepoLookup::NotARepo, + Some(Some(_)) => { + self.scm_probe(host, cwd, cx); + match status_of(cx, id, cwd) { + Some(status) => { + let root = status.root.clone(); + self.scm.roots.insert(key, root.clone()); + RepoLookup::Root(root) + } + None => 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; } - let Some((id, cwd)) = self.right_panel.diff_cwd.clone() else { + 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, + host: &SharedHost, + root: &Path, + 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(group, entries.len(), 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(host, root, 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() + } + + 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, + group: ScmGroup, + count: usize, + collapsed: bool, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + h_flex() + .id(SharedString::from(format!("scm-group-{group:?}"))) + .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()), + ) + .into_any_element() + } + + fn scm_file_row( + &self, + host: &SharedHost, + root: &Path, + 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 selected = self.diff_overlay_focus(host.id(), root) == Some(path.as_str()); + let source = group_diff_source(group); + + h_flex() + .id(SharedString::from(format!("scm-row-{group:?}-{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 host_id = host.id(); + let root = root.to_path_buf(); + let path = path.clone(); + cx.listener(move |this, _, window, cx| { + this.open_diff_overlay( + host_id, + root.clone(), + source.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + .child(git_badge(letter, status_color(deco, cx), &mono)) + .child( + div() + .flex_none() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if deco == DecoStatus::Deleted { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .when(deco == DecoStatus::Deleted, |s| s.line_through()) + .child(name.to_string()), + ) + // 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()), + ) + }) + .into_any_element() + } + + /// 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(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 + let Some(seen) = cx .try_global::() - .and_then(|cache| cache.status_for(id, &cwd)) + .and_then(|cache| cache.status_for(repo.host, &repo.root)) else { return; }; - let stale = status.branch != snap.branch || (status.added, status.removed) != snap.totals(); - if stale { - self.spawn_right_panel_diff(host, cwd, cx); + if self.scm.last_tab_status.as_ref() == Some(&seen) { + return; } + self.scm.last_tab_status = Some(seen); + self.scm_invalidate(&repo, cx); } + + /// Send the next look at a repository back to git. + pub(crate) fn scm_invalidate(&mut self, repo: &RepoKey, cx: &mut Context) { + cx.default_global::() + .bump(repo.host, &repo.root); + 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(); + } +} + +/// 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 } #[cfg(test)] mod tests { + use super::*; 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)); + } + } fn tab_from(json: &str) -> RightPanelTab { serde_json::from_str::(json) @@ -295,4 +781,28 @@ mod tests { 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) + })); + } } diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 55b2405b..6b738e1d 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -67,9 +67,27 @@ pub(crate) struct ScmPanelState { /// 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. Absent means open, so a group that has - /// never been touched renders expanded. + /// 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. Cached because + /// the root is what every write and every cache lookup is keyed by, and + /// only a `git status` can say what it is. + pub(crate) roots: HashMap<(HostId, PathBuf), 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. @@ -87,6 +105,25 @@ impl ScmPanelState { 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)] From 45879d1c22f6203b4d3af4950df546b24ea12743 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:19:50 +0800 Subject: [PATCH 16/36] feat(diff): add a unified view and name the source in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay could only ever show one thing one way: `git diff HEAD`, side by side. Both halves of that are now choices. A segmented control in the header switches between side-by-side and unified, stored in `Config::diff_view` so the choice survives the next open. Unified is measured against the split cell rather than designed next to it: the same 19px row, the same type, and the same 0.12 wash behind an addition and a removal. It differs only where the shape forces it — 34px per line-number gutter instead of 42, and a column of its own for `+`/`−`, without which the context lines' code would start two characters left of everything else. The header now says which patch it is showing. A branch name for the worktree and for HEAD as before; the same with a STAGED chip for the index, which is otherwise indistinguishable; the commit glyph and a short object id for a commit or a range. The subject and author of a commit are not there yet — `DiffSource::Commit` carries only the rev, and buying them costs another round trip that the commit detail view will be making anyway. Two pieces of coupling go with it. `PANEL_DIFF_SOURCE` no longer decides whether the panel's snapshot may seed an overlay, or whether an overlay has gone stale: the first is settled by the snapshot's own source, the second by the overlay's. A commit and a range never go stale at all, and the two sources the cached `--numstat HEAD` counts cannot describe now compare `ScmData` epochs instead — read when the probe starts, so a write landing under it is not mistaken for one the result reflects. And the file cards drop their private A/M/D/R table for the shared `status_glyph`/`status_color`, so a file wears the same letter here as in the panel and the tree. --- src/ui/diff_overlay.rs | 594 +++++++++++++++++++++++++++++++++++++---- src/ui/diff_rows.rs | 5 - 2 files changed, 548 insertions(+), 51 deletions(-) diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 17bd7aa8..eb914b4e 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3,25 +3,36 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui::{ - AnyElement, FocusHandle, FontWeight, KeyDownEvent, Pixels, Window, div, prelude::*, px, + AnyElement, FocusHandle, FontWeight, 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, DiffSource, DiffStats, FileDiff, FileStatus, + self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffSource, DiffStats, FileDiff, FileStatus, LineKind, MAX_RENDERED_FILES, Truncation, }; use crate::ui::app::Tty7App; -use crate::ui::diff_rows::{Side, SplitCell, SplitRow, split_hunk}; +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::status::{status_color, status_glyph}; -/// What the right panel's shared probe still asks git for, and therefore what -/// an overlay opened from the panel has to ask for too: the seed below is only -/// sound while the two agree. Both move together when the panel splits into -/// staged and unstaged groups. +/// What the right panel's shared probe asks git for, and so what an overlay +/// opened from the sidebar shows. +/// +/// The panel's own contract, not a default the rest of the file leans on: it +/// is read where the panel's request is issued, where the panel's answer is +/// filed away, and at the one entry point that has no source of its own to +/// name. Whether an overlay may reuse the panel's snapshot is settled by that +/// snapshot's own `source`, and whether an overlay has gone stale by the +/// overlay's — so when the panel splits into staged and unstaged groups, this +/// constant is the only thing that has to move. const PANEL_DIFF_SOURCE: DiffSource = DiffSource::Head; pub(crate) enum DiffLoad { @@ -42,6 +53,33 @@ pub(crate) struct DiffOverlayState { pub(crate) expanded: HashMap, pub(crate) focus: 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 + } } impl Tty7App { @@ -105,15 +143,21 @@ impl Tty7App { None => {} } // Skipping the "Reading…" flash is only allowed when the panel's - // snapshot answers the same question this overlay is asking. + // snapshot answers the same question this overlay is asking. The + // snapshot says which question that was, so this stays right through + // whatever the panel decides to probe for next. let seed = match (&self.right_panel.diff_cwd, &self.right_panel.diff) { (Some(panel_key), Some(Some(snap))) - if source == PANEL_DIFF_SOURCE && *panel_key == (host, cwd.clone()) => + if snap.source == source && *panel_key == (host, cwd.clone()) => { DiffLoad::Ready(Arc::clone(snap)) } _ => DiffLoad::Loading, }; + let epoch = match &seed { + DiffLoad::Ready(snap) => Some(scm_epoch(cx, host, &snap.root)), + _ => None, + }; self.remember_active_pane(window, cx); let Some(tab) = self.tabs.get_mut(active) else { return; @@ -129,6 +173,7 @@ impl Tty7App { expanded: HashMap::new(), focus, scroll: gpui::ScrollHandle::new(), + epoch, }); window.focus(&focus_handle, cx); self.spawn_diff_probe(cx); @@ -158,11 +203,7 @@ impl Tty7App { fn spawn_diff_probe(&mut self, cx: &mut Context) { let active = self.active; - let Some(overlay) = self - .tabs - .get_mut(active) - .and_then(|t| t.diff_overlay.as_mut()) - else { + let Some(overlay) = self.tabs.get(active).and_then(|t| t.diff_overlay.as_ref()) else { return; }; if overlay.loading { @@ -171,10 +212,24 @@ impl Tty7App { 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) + .and_then(|t| t.diff_overlay.as_mut()) + else { + return; + }; overlay.loading = true; + overlay.epoch = epoch; self.spawn_diff_probe_for(host, cwd, source, cx); } @@ -231,6 +286,9 @@ impl Tty7App { snap: Option>, cx: &mut Context, ) { + // 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)); let mut landed = false; for tab in self.tabs.iter_mut() { let Some(overlay) = tab @@ -241,6 +299,7 @@ impl Tty7App { 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, @@ -278,23 +337,37 @@ impl Tty7App { if overlay.loading { return; } - // The cached counts come from `git diff --numstat HEAD`, so only a - // HEAD snapshot is comparable to them. A staged or unstaged snapshot - // would differ the moment anything is staged, and re-probe forever; a - // commit or a range cannot go stale at all. - if overlay.source != DiffSource::Head { - return; - } 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); } } @@ -369,6 +442,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", @@ -385,17 +460,35 @@ 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, + )) + }) .when_some(focused_name(overlay), |bar, name| { bar.child( div().occlude().flex_shrink_0().child( @@ -476,6 +569,25 @@ impl Tty7App { }, ) .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( @@ -515,6 +627,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 { @@ -533,7 +646,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; @@ -592,19 +705,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), - FileStatus::Copied => ("C", cx.theme().muted_foreground), - FileStatus::TypeChanged => ("T", cx.theme().warning), - FileStatus::Unmerged => ("U", cx.theme().danger), - }; + 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(), @@ -711,7 +818,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 @@ -734,8 +847,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 { @@ -819,6 +949,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)]; @@ -859,8 +1061,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())), ); @@ -881,6 +1083,98 @@ 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. +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, +} + +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, + }; + 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("STAGED")), + DiffSource::Commit { rev } => SourceSubject { + icon: "icons/git-commit.svg", + text: short_rev(rev), + chip: None, + is_rev: true, + }, + DiffSource::Range { base, head } => SourceSubject { + icon: "icons/git-commit.svg", + text: format!("{}…{}", short_rev(base), short_rev(head)), + chip: None, + is_rev: true, + }, + } +} + +/// 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) @@ -989,6 +1283,98 @@ mod tests { ); } + #[test] + 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 { + rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + }, + 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"); + } + + 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(), @@ -1455,6 +1841,122 @@ mod overlay_gpui_tests { ); } + 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 { + rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + }, + 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) { diff --git a/src/ui/diff_rows.rs b/src/ui/diff_rows.rs index 8a10215b..4aa16876 100644 --- a/src/ui/diff_rows.rs +++ b/src/ui/diff_rows.rs @@ -85,10 +85,6 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec { rows } -// Read by the unified renderer, which lands with the view toggle. The rows -// themselves belong here now so that both shapes of the same hunk are built — -// and tested — in one place rather than growing a second copy later. -#[allow(dead_code)] pub(crate) struct UnifiedRow { pub(crate) old: Option, pub(crate) new: Option, @@ -100,7 +96,6 @@ pub(crate) struct UnifiedRow { /// 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. -#[allow(dead_code)] pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec { lines .iter() From 71081236d0e9f4d00079067f04e84ab61191e5a6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:25:39 +0800 Subject: [PATCH 17/36] feat(git): watch .git, debounce it, and stop when nobody is looking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source control data layer had a cache and a way to invalidate it, but nothing that noticed a change on its own. This adds the three pieces that make the panel live: a `.git` watch, one debounce in front of every source of invalidation, and a subscription gate so a repository nobody is looking at costs nothing at all. The watch covers ``, the common dir when a linked worktree makes them different, the three `refs/` roots and the namespaces under `refs/heads` — `Host::watch` does not recurse, so each one has to be named. Never the working tree: recursively watching one over SSH is a disaster, and edits there already reach the same bus from the file tree, the editor and the command boundary. Everything invalidating goes through one `scm_invalidate`, so a `git add` and the watcher event it provokes fall in one window and cost one probe between them. There is no self-triggering to defend against: the read path sets `GIT_OPTIONAL_LOCKS=0`, whose only effect is to stop `git status` writing back `.git/index`, so probes provably cannot wake the watch that schedules them. Also fixes four things that could not survive contact with a live panel: - A network slot was released in `run_in`'s landing closure, which does not run if the view died first — one lost slot per abandoned push, forever. The claim is now a guard that rides in the work closure instead, which runs either way and needs no `App` to release. - A probe that found no repository never wrote `read_at`, so the root stayed stale and every frame spawned another `rev-parse`. A pane sitting in an ordinary directory is normal, and "there is no repository here" is an answer like any other; `known_status` now reports it as one. - Nothing marked a window dirty when a probe landed, so the panel waited for the next unrelated repaint. - `ScmData::clear_host` had no caller, and neither did `GitStatusCache`'s equivalent: a dropped SSH link left the branch and the file list the machine had on the way down, with nothing to say so. --- src/terminal/git_data.rs | 1307 ++++++++++++++++++++++++++++++++++-- src/terminal/git_status.rs | 62 ++ src/ui/app.rs | 1 + 3 files changed, 1323 insertions(+), 47 deletions(-) diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 11f3aba0..2277a1e8 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -12,22 +12,25 @@ //! entries a `git add` touched is a losing game; bumping a counter for the //! repository and letting readers notice they are behind is not. -// This module is the contract the panel, the file tree and the `.git` watcher -// are built against, and it landed before any of them. Without the allow every -// item here reports unused and the real dead code elsewhere gets lost in the -// noise. Take it off once the panel calls `scm_refresh` — by then anything -// still unused genuinely is. +// The watcher and the subscription gate now use this module, but the panel and +// the file tree — the things that read the status and run the writes — are +// still landing alongside it, so `status_of`, `index_of`, `run_git_op`, +// `shell_quote` and the `FileTree`/`Editor` subscribers have no callers yet. +// Take the allow off with the last of them; anything still unused then is. #![allow(dead_code)] +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, WorkingTreeStatus, probe_status}; use crate::ui::app::Tty7App; -use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost}; +use crate::ui::host_ops::{ByHost, Host, HostId, HostOps, InFlight, SharedHost, WatchSub}; /// How many network operations one host may have in flight. /// @@ -37,6 +40,270 @@ use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost}; /// so the client, not the server, keeps the number small. pub const MAX_CONCURRENT_NETWORK_OPS: usize = 2; +/// 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() + } +} + +/// 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, + debounce: Debounce, +} + #[derive(Default)] pub struct ScmData { /// repo root → the last status we read. @@ -48,7 +315,17 @@ pub struct ScmData { /// repo root → the epoch the cached status was read at. read_at: ByHost, probes: InFlight<(HostId, PathBuf)>, - network: ByHost, + 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 {} @@ -62,6 +339,25 @@ impl ScmData { 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) } @@ -91,10 +387,122 @@ impl ScmData { self.epoch.clear_host(host); self.read_at.clear_host(host); self.network.clear_host(host); + self.watches.retain(|(held, _), _| *held != host); + self.subs.clear_host(host); + self.wipe += 1; } - fn network_slots(&self, host: HostId, root: &Path) -> usize { - self.network.get(host, root).copied().unwrap_or(0) + /// 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, and nothing on the way. + fn unwatched(&self) -> Vec<(HostId, PathBuf)> { + self.subs + .subscribed() + .into_iter() + .filter(|key| match self.watches.get(key) { + Some(watch) => watch.sub.is_none() && !watch.opening, + 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>) { + let watch = self.watch_mut(host, root); + watch.opening = false; + 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) } } @@ -127,10 +535,14 @@ impl Tty7App { 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(); + let again = host.clone(); HostOps::run_detached( - host.clone(), + host, cx, move |h| { let status = probe_status(h, &probe_root)?; @@ -139,20 +551,266 @@ impl Tty7App { }, move |cx, result| { let data = cx.default_global::(); - // The return says whether the epoch moved while this was in - // flight. Nothing to do with it: `read_at` records the epoch - // the read *started* at, so a bump has already left this - // result behind and the next look reprobes on its own. - data.probes.finish(&key); - if let Some((status, index)) = result { - data.status.insert(id, root.clone(), status); - data.index.insert(id, root.clone(), index); - data.read_at.insert(id, root.clone(), at); + // `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; + } + match result { + Some((status, index)) => { + data.status.insert(id, root.clone(), status); + data.index.insert(id, root.clone(), index); + } + // 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. + None => { + data.status.remove(id, root.as_path()); + data.index.remove(id, root.as_path()); + } + } + data.read_at.insert(id, root.clone(), at); + // `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. + cx.refresh_windows(); + if superseded { + let _ = this.update(cx, |app, cx| app.scm_refresh(again, 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 target = self.scm_panel_target(window, 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 target.is_none() && cx.try_global::().is_none_or(ScmData::is_quiet) { + return; + } + let dropped = cx + .default_global::() + .subscriptions() + .declare(ScmWatcher::Panel, target); + if let Some((host, root)) = dropped { + cx.default_global::().drop_watch(host, &root); + } + + for (host, root) in cx.default_global::().unwatched() { + 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())) + } + + /// 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); + } + } + + /// 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 probe_root = root.clone(); + HostOps::run( + host, + cx, + move |h| { + 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 + } + } + }, + move |app, sub: Option>, cx| { + app.scm_watch_opened(id, root, sub_gen, sub, cx) + }, + ); + } + + fn scm_watch_opened( + &mut self, + host: HostId, + root: PathBuf, + sub_gen: 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. + if data.generation(host, &root) != sub_gen || !data.is_subscribed(host, &root) { + data.finish_watch_open(host, &root, None); + return; + } + let events = sub.as_ref().map(|sub| sub.events().clone()); + data.finish_watch_open(host, &root, sub); + 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 @@ -171,53 +829,49 @@ impl Tty7App { }; let head = status.head.clone(); let id = host.id(); - let network = op.is_network(); - if network { - let data = cx.default_global::(); - if data.network_slots(id, &root) >= MAX_CONCURRENT_NETWORK_OPS { - return; + // 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 => return, } - let next = data.network_slots(id, &root) + 1; - data.network.insert(id, root.clone(), next); - } + } else { + None + }; let op_root = root.clone(); HostOps::run_in( - host.clone(), + host, window, cx, - move |h| run_op(h, &op_root, &op, &head), + move |h| { + let outcome = run_op(h, &op_root, &op, &head); + drop(slot); + outcome + }, move |app, result, window, cx| { - if network { - let data = cx.default_global::(); - let left = data.network_slots(id, &root).saturating_sub(1); - data.network.insert(id, root.clone(), left); - } - cx.default_global::().bump(id, &root); - app.on_git_op_done(host, root, 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); + app.on_git_op_done(result, window, cx); }, ); } fn on_git_op_done( &mut self, - host: SharedHost, - root: PathBuf, result: Result, window: &mut Window, cx: &mut Context, ) { - match result { - Ok(_) => { - self.scm_refresh(host, root, cx); - cx.notify(); - } - Err(err) => { - self.report_git_op_error(&err, window, cx); - self.scm_refresh(host, root, cx); - } + 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 @@ -245,6 +899,54 @@ impl Tty7App { } } +/// 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 @@ -343,4 +1045,515 @@ mod tests { 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 mut data = ScmData::default(); + data.subscriptions().acquire(HostId::LOCAL, &root()); + assert_eq!(data.unwatched(), 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().is_empty()); + + // A watch that failed to open leaves nothing behind, so the next + // frame is free to try again. + data.finish_watch_open(HostId::LOCAL, &root(), None); + assert_eq!(data.unwatched(), vec![(HostId::LOCAL, root())]); + } + + #[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 ---------------------------------------- + + 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", + ]; + 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 = scm_watch_dirs(&*host, &repo).expect("a repository was just created here"); + assert_eq!(plain[0], repo.join(".git")); + assert!(plain.contains(&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 = scm_watch_dirs(&*host, &linked).expect("the linked worktree is a repository"); + assert!( + dirs[0].starts_with(repo.join(".git").join("worktrees")), + "HEAD and index live in the worktree's own git dir, got {dirs:?}" + ); + assert!( + dirs.contains(&repo.join(".git")), + "packed-refs lives in the common dir, and it is a different one, got {dirs:?}" + ); + assert!( + dirs.contains(&repo.join(".git").join("refs").join("heads").join("feat")), + "`feat/x` needs its namespace listed: the watch does not recurse, got {dirs:?}" + ); + } } diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index a52a1b7a..03d407df 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -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()) { @@ -265,6 +284,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/ui/app.rs b/src/ui/app.rs index 5993fe73..01717d14 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5163,6 +5163,7 @@ impl Render for Tty7App { let prof = crate::ui::perf::enabled().then(std::time::Instant::now); 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); } else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) { From 200d27286a2bf8bf5adfb7c21d0167a981f407f6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:27:06 +0800 Subject: [PATCH 18/36] feat(scm): stage, unstage and discard from the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a row brings up its buttons, absolutely positioned over an opaque backing so they cover the tail of the directory instead of pushing it aside — hovering must not move a pixel of the list under the pointer. The header of each group carries the same verbs applied to all of it. Which buttons appear follows the group: Changes and Untracked get discard and stage, Staged gets unstage, and a conflict gets "open" and "mark resolved" — the latter being `git add`, because git has no other verb for resolving. Anything that can lose work goes through `window.prompt` first, keyed off `GitOp::destructive` so the data layer stays the one place that decides what is dangerous. That is the project's only confirmation mechanism; no modal component is introduced. A path that is not valid UTF-8 cannot be sent to git as a pathspec, so its own buttons are disabled with a tooltip saying why, and group actions leave it out — `validate` rejects the whole operation over one of them, which would otherwise punish everyone else in the group. --- src/ui/scm/actions.rs | 250 +++++++++++++++++--- src/ui/scm/panel.rs | 520 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 724 insertions(+), 46 deletions(-) diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 86087de1..d406969f 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -1,20 +1,25 @@ //! Where the source control actions and palette commands land. //! -//! Two of them are finished here because they are pure view state and have -//! nothing to wait for. The rest funnel through one `ScmIntent` match so the -//! wiring — action, key binding, palette entry, menu item — can be verified -//! now, and each arm gets its body filled in by the step that owns it. +//! 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; +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 -/// (later) the row buttons all want the same behaviour, and routing them -/// through one match is what keeps the three from drifting apart. +/// 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, @@ -53,31 +58,224 @@ impl Tty7App { 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, + ) { + let Some(host) = HostRegistry::get(cx, repo.host) else { + return; + }; + let Some(loss) = op.destructive() else { + self.run_git_op(host, repo.root, op, window, cx); + return; + }; + let answer = window.prompt( + PromptLevel::Warning, + &confirm_question(&op, loss), + None, + &[t(L10nKey::Cancel), confirm_verb(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, window, cx) + }); + }) + .detach(); + } + pub(crate) fn run_scm_action( &mut self, intent: ScmIntent, - _window: &mut gpui::Window, + window: &mut Window, cx: &mut Context, ) { + let Some(repo) = self.scm.active_repo().cloned() else { + return; + }; match intent { - // Refresh is the one verb the panel can already answer: the flat - // diff probe behind the old Changes tab is exactly what it means. - ScmIntent::Refresh => self.right_panel_refresh_changes(cx), - // Staging, discarding and committing need `core::git::ops`, which - // arrives with the row buttons and the commit box. - ScmIntent::StageAll - | ScmIntent::UnstageAll - | ScmIntent::DiscardAll - | ScmIntent::Commit - | ScmIntent::CommitAmend => {} - // The network verbs and the branch switcher come with the - // repository header row. - ScmIntent::Sync - | ScmIntent::Push - | ScmIntent::Pull - | ScmIntent::Fetch - | ScmIntent::CheckoutBranch - | ScmIntent::CreateBranch => {} + ScmIntent::Refresh => self.scm_invalidate(&repo, cx), + 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), + // Committing needs the message box, which lands with it. + ScmIntent::Commit | ScmIntent::CommitAmend => {} + 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, + ), + // Both open a picker rather than doing anything, so they are the + // panel's business and not this match's. + ScmIntent::CheckoutBranch | ScmIntent::CreateBranch => {} } } + + /// Throw away everything: tracked 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. + 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 tracked: Vec<_> = status + .unstaged() + .chain(status.staged()) + .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(); + if !tracked.is_empty() { + self.scm_op( + repo.clone(), + GitOp::DiscardWorktree { paths: tracked }, + window, + cx, + ); + } + if !untracked.is_empty() { + let directories = untracked.iter().any(|p| p.as_str().ends_with('/')); + self.scm_op( + repo, + GitOp::DiscardUntracked { + paths: untracked, + directories, + }, + window, + cx, + ); + } + } + + /// Push the current branch to its upstream, or publish it if it has none. + 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. + /// + /// A branch with no upstream has nothing to pull, so sync is a publish. + 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( + repo.clone(), + GitOp::Pull { + mode: PullMode::FfOnly, + }, + window, + cx, + ); + } + self.scm_push(repo, false, window, cx); + } +} + +/// `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 { + 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(loss: Destructive) -> &'static str { + match loss { + Destructive::RewritesHistory => t(L10nKey::ScmAmendLastCommit), + _ => t(L10nKey::ScmDiscard), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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/panel.rs b/src/ui/scm/panel.rs index 2634e228..17e00c9b 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -11,10 +11,15 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; -use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex, v_flex}; +use gpui_component::button::Button; +use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Disableable as _, Icon, IconName, h_flex, v_flex}; use tty7_core::core::git::diff::MAX_RENDERED_FILES; -use tty7_core::core::git::status::{ChangeCode, DecoStatus, StatusEntry, WorkingTreeStatus}; +use tty7_core::core::git::ops::GitOp; +use tty7_core::core::git::status::{ + ChangeCode, DecoStatus, RepoPath, StatusEntry, WorkingTreeStatus, +}; use crate::terminal::git_data::status_of; use crate::terminal::git_diff::DiffSource; @@ -38,6 +43,14 @@ const BADGE_W: f32 = 14.; /// hovered row's background is wider than its text on both sides. const ROW_INSET: f32 = 4.; +/// The row-button tile, one step below `TILE_SIZE_SM`. +/// +/// These belong next to the other tile sizes in `app.rs`; they are here +/// because that file is being rewritten elsewhere this cycle, and moving them +/// is a one-line change once it settles. +pub(crate) const TILE_SIZE_XS: f32 = 18.; +pub(crate) const TILE_GLYPH_XS: f32 = 11.; + /// 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. @@ -103,10 +116,11 @@ impl Tty7App { return self.scm_shell(title, body); }; - self.scm.repo = Some(RepoKey { + let repo = RepoKey { host: host.id(), - root: root.clone(), - }); + root, + }; + self.scm.repo = Some(repo.clone()); let count = (status.total_entries > 0).then(|| status.total_entries.to_string()); let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); @@ -120,7 +134,7 @@ impl Tty7App { return self.scm_shell(title, body); } - let body = self.scm_groups(&host, &root, &status, cx); + let body = self.scm_groups(&repo, &status, cx); self.scm_shell(title, body) } @@ -264,8 +278,7 @@ impl Tty7App { fn scm_groups( &mut self, - host: &SharedHost, - root: &Path, + repo: &RepoKey, status: &Arc, cx: &mut Context, ) -> AnyElement { @@ -280,13 +293,13 @@ impl Tty7App { continue; } let collapsed = self.scm.group_collapsed(group, entries.len()); - list = list.child(self.scm_group_header(group, entries.len(), collapsed, cx)); + 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(host, root, group, entry, cx)); + list = list.child(self.scm_file_row(repo, group, entry, cx)); } if entries.len() > shown { list = list.child(self.scm_note( @@ -322,15 +335,21 @@ impl Tty7App { fn scm_group_header( &self, + repo: &RepoKey, group: ScmGroup, - count: usize, + 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(SharedString::from(format!("scm-group-{group:?}"))) + .id(id.clone()) + .group(id) + .relative() .items_center() .gap(px(8.)) .h(px(ROW_H)) @@ -377,13 +396,13 @@ impl Tty7App { .text_color(cx.theme().muted_foreground.opacity(0.75)) .child(count.to_string()), ) + .child(actions) .into_any_element() } fn scm_file_row( &self, - host: &SharedHost, - root: &Path, + repo: &RepoKey, group: ScmGroup, entry: &StatusEntry, cx: &mut Context, @@ -393,11 +412,22 @@ impl Tty7App { let path = entry.path.as_str().to_string(); let (name, dir) = split_display_path(&path); let (letter, deco) = row_status(entry, group); - let selected = self.diff_overlay_focus(host.id(), root) == Some(path.as_str()); + let selected = self.diff_overlay_focus(repo.host, &repo.root) == Some(path.as_str()); let source = group_diff_source(group); + 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(SharedString::from(format!("scm-row-{group:?}-{path}"))) + .id(id.clone()) + .group(id) + .relative() .items_center() .gap(px(8.)) .h(px(ROW_H)) @@ -408,13 +438,12 @@ impl Tty7App { .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 root = root.to_path_buf(); + let repo = repo.clone(); let path = path.clone(); cx.listener(move |this, _, window, cx| { this.open_diff_overlay( - host_id, - root.clone(), + repo.host, + repo.root.clone(), source.clone(), Some(path.clone()), window, @@ -422,6 +451,14 @@ impl Tty7App { ); }) }) + .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)) .child( div() @@ -449,9 +486,265 @@ impl Tty7App { .child(dir.to_string()), ) }) + .child(actions) .into_any_element() } + /// The buttons that appear over a hovered row. + /// + /// Absolutely positioned and opaque, so they cover 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. + 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 = h_flex() + .occlude() + .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()); + + 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 = h_flex() + .occlude() + .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()); + + 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() + } + + /// An 18px 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. /// @@ -577,6 +870,97 @@ pub(crate) fn starts_collapsed(group: ScmGroup, count: usize) -> bool { group == ScmGroup::Untracked && count > UNTRACKED_AUTO_COLLAPSE } +/// 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::*; @@ -713,6 +1097,102 @@ mod tests { } } + #[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); + } + } + } + fn tab_from(json: &str) -> RightPanelTab { serde_json::from_str::(json) .expect("config deserializes") From 72776d3367ee514f307683b3889a6c6cd37ecc38 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:29:26 +0800 Subject: [PATCH 19/36] fix(git): only repaint when a status probe changed what is shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing a probe called refresh_windows unconditionally, so the answer that changes nothing — a re-read confirming what is already drawn, or the "still not a repository" reply for an ordinary directory — cost a frame anyway. rewriting_a_file_in_a_displayed_directory_costs_no_frames caught it: that test asserts the file tree redraws nothing when a file it is showing is rewritten, and the probe the tree kicks off for its own root was putting a frame behind every such write. Comparing the whole WorkingTreeStatus is O(entries), but it runs once per probe rather than once per frame, which is the trade this is making. --- src/terminal/git_data.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 2277a1e8..34b43e4b 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -560,10 +560,15 @@ impl Tty7App { if data.wipe != wipe || data.generation(id, &root) != sub_gen { return; } - match result { + let changed = match result { Some((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 @@ -571,15 +576,25 @@ impl Tty7App { // cwd is an ordinary directory would otherwise spawn a // `rev-parse` per frame, forever. None => { - data.status.remove(id, root.as_path()); + let held = data.status.remove(id, root.as_path()).is_some(); data.index.remove(id, root.as_path()); + held } - } + }; data.read_at.insert(id, root.clone(), at); // `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. - cx.refresh_windows(); + // + // 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 { let _ = this.update(cx, |app, cx| app.scm_refresh(again, root, cx)); } From c2f7574bfa0cb80ba9ce9e1415c170b6b6c6221c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:36:01 +0800 Subject: [PATCH 20/36] feat(scm): commit from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-line message box over a primary button, both pinned above the file list so they stay reachable however far down the changes go. The box rests at 30px — the height of `panel_search`, so every input row in the panel sits on one line — and grows to six rows. `secondary-enter` commits, bound inside the `ScmCommit` key context the box installs. On macOS that chord is `ToggleFullscreen` at the window level; the two coexist because gpui resolves a keystroke by walking outwards from the focused node, and a test asserts exactly that — the chord is shared and scope is the only thing telling them apart. The button says what pressing it would do: "Commit All" when nothing is staged, since a plain `git commit` would commit nothing and a silent `-a` would be a lie. Amend is a menu item rather than a checkbox row, because 260px does not have a row to spare. Drafts are keyed by working tree, so switching tabs or panes keeps the message. A commit is only cleared from the box once HEAD has actually moved: clearing it on dispatch would lose a carefully written message to a pre-commit hook that rejects it. --- src/ui/scm/actions.rs | 99 ++++++++- src/ui/scm/panel.rs | 499 +++++++++++++++++++++++++++++++++++++++++- src/ui/scm/state.rs | 8 + 3 files changed, 593 insertions(+), 13 deletions(-) diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index d406969f..0a739612 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -24,6 +24,10 @@ use crate::ui::scm::state::RepoKey; 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. + CommitAndPush, + CommitAndSync, StageAll, UnstageAll, DiscardAll, @@ -110,8 +114,21 @@ impl Tty7App { 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), - // Committing needs the message box, which lands with it. - ScmIntent::Commit | ScmIntent::CommitAmend => {} + ScmIntent::Commit => { + let amend = self.scm.amend; + self.scm_commit(repo, amend, window, cx); + } + ScmIntent::CommitAmend => self.scm_commit(repo, true, window, cx), + ScmIntent::CommitAndPush => { + let amend = self.scm.amend; + self.scm_commit(repo.clone(), amend, window, cx); + self.scm_push(repo, false, window, cx); + } + ScmIntent::CommitAndSync => { + let amend = self.scm.amend; + self.scm_commit(repo.clone(), amend, window, cx); + self.scm_sync(repo, window, cx); + } ScmIntent::Sync => self.scm_sync(repo, window, cx), ScmIntent::Push => self.scm_push(repo, false, window, cx), ScmIntent::Pull => self.scm_op( @@ -137,6 +154,84 @@ impl Tty7App { } } + /// 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, + 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, + ); + return; + } + let all = crate::ui::scm::panel::commit_stages_everything(&status, amend); + // Remembered so the box can be cleared once HEAD actually moves — + // see `scm_commit_landed`. + self.scm.committing = Some((repo.clone(), status.head.clone(), message.clone())); + self.scm.amend = false; + self.scm_op( + repo, + GitOp::Commit { + message, + amend, + signoff: false, + no_verify: false, + all, + }, + 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 everything: tracked edits and untracked files alike. /// /// Two operations, because git has no single command for it — diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 17e00c9b..fec392f2 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -10,10 +10,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; -use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; -use gpui_component::button::Button; -use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; -use gpui_component::{ActiveTheme as _, Disableable as _, Icon, IconName, h_flex, v_flex}; +use gpui::{AnyElement, Context, Focusable as _, SharedString, Window, div, prelude::*, px}; +use gpui_component::button::{Button, 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; @@ -27,6 +30,7 @@ use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::host_ops::{HostId, SharedHost}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::git_badge; +use crate::ui::scm::ScmIntent; use crate::ui::scm::path::split_display_path; use crate::ui::scm::state::{RepoKey, ScmGroup}; use crate::ui::scm::status::{status_color, status_glyph}; @@ -51,6 +55,11 @@ const ROW_INSET: f32 = 4.; pub(crate) const TILE_SIZE_XS: f32 = 18.; pub(crate) const TILE_GLYPH_XS: f32 = 11.; +/// 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"; + /// 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. @@ -125,17 +134,223 @@ impl Tty7App { let count = (status.total_entries > 0).then(|| status.total_entries.to_string()); let title = self.panel_title(t(L10nKey::PanelScmTitle), count, None, window, cx); - if status.is_clean() { - let body = self.panel_empty( + let commit = self.scm_commit_box(&repo, &status, window, cx); + let buttons = self.scm_commit_buttons(&repo, &status, cx); + let body = if status.is_clean() { + self.panel_empty( t(L10nKey::PanelNoChanges), Some(t(L10nKey::PanelNoChangesHint)), cx, - ); - return self.scm_shell(title, body); + ) + } else { + self.scm_groups(&repo, &status, cx) + }; + self.scm_shell_with(title, vec![commit, buttons], body) + } + + /// 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. + 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 theme = cx.theme(); + div() + .key_context(COMMIT_KEY_CONTEXT) + .flex_none() + .px(px(CONTENT_INSET)) + .pt(px(6.)) + .child( + div() + // The resting height of `panel_search`, so every input + // row in the panel sits on the same line. + .min_h(px(30.)) + .max_h(px(120.)) + .rounded(crate::ui::rounding::CARD_RADIUS) + .border_1() + .border_color(if focused { theme.ring } else { theme.border }) + .bg(theme.input) + .px(px(8.)) + .py(px(6.)) + .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(1, 6) + .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; } - let body = self.scm_groups(&repo, &status, cx); - self.scm_shell(title, body) + 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 + } + + 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(); + h_flex() + .flex_none() + .gap(px(4.)) + .px(px(CONTENT_INSET)) + .pt(px(6.)) + .pb(px(8.)) + .child( + Button::new("scm-commit") + .primary() + .h(px(28.)) + .flex_1() + .label(t(plan.label)) + .disabled(!plan.enabled) + .when(!plan.enabled, |b| b.tooltip(t(L10nKey::ScmNothingToCommit))) + .on_click(cx.listener(move |this, _, window, cx| { + this.scm_commit(repo_for_button.clone(), this.scm.amend, window, cx); + })), + ) + .child(self.scm_commit_menu(repo, cx)) + .into_any_element() + } + + fn scm_commit_menu(&self, repo: &RepoKey, cx: &mut Context) -> AnyElement { + let amend = self.scm.amend; + crate::ui::tab_strip::chrome_tile_sized( + Button::new("scm-commit-menu").icon(Icon::new(IconName::ChevronDown)), + 28., + 12., + false, + cx, + ) + .rounded(crate::ui::rounding::CARD_RADIUS) + .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. @@ -144,6 +359,17 @@ impl Tty7App { /// 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 { let scroller = div() .id("panel-scm-body") .flex_1() @@ -155,6 +381,7 @@ impl Tty7App { .flex_1() .min_h_0() .child(title) + .children(pinned) .child(crate::ui::scrollbar::with_vertical_scrollbar( "panel-scm-scrollbar", scroller, @@ -870,6 +1097,42 @@ pub(crate) fn starts_collapsed(group: ScmGroup, count: usize) -> bool { group == ScmGroup::Untracked && count > UNTRACKED_AUTO_COLLAPSE } +/// 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, +} + +/// 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(); + CommitPlan { + label, + enabled: (staged || tracked_edits || amend) && (has_message || amend), + } +} + +/// 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. @@ -964,10 +1227,11 @@ fn verb_all_tooltip(verb: RowVerb) -> &'static str { #[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}; + use tty7_core::core::git::status::{ConflictKind, EntryKind, HeadState, RepoPath}; fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry { StatusEntry { @@ -1193,6 +1457,219 @@ mod tests { } } + 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)); + } + + #[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") diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 6b738e1d..4a764fe4 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use gpui::Entity; use gpui_component::input::InputState; +use tty7_core::core::git::status::HeadState; use crate::ui::host_ops::HostId; @@ -64,6 +65,13 @@ pub(crate) struct ScmPanelState { /// `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, From 7695287f4a010af4ba8d592cf63229721d42851e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:36:20 +0800 Subject: [PATCH 21/36] feat(git): connect the remaining invalidation sources and subscribers The watcher landed with one subscriber declared and one invalidation source wired, because the three others live in files it did not own. Wiring them: - the file tree announces working-tree edits it sees, skipping anything under .git so the repository's own watch is not doubled into the same window - the editor announces a save, which is the working-tree edit neither watch can see when the tree is not showing that directory - a pane announces a command boundary, which is the only signal for a command that edits a file nowhere anyone is looking. It only moves the epoch: the probe rides the app's next render, which refresh_git_status is about to cause anyway by writing GitStatusCache The tree and the editor also declare themselves as watchers, so decorations and gutters keep a repository live on their own rather than only while the panel happens to be the visible tab. Both target the active pane's repository, which is what the panel picks too, so the three subscriptions usually collapse onto one watch. --- src/terminal/git_data.rs | 62 ++++++++++++++++++++++++++++++++++------ src/terminal/view.rs | 30 +++++++++++++++++++ src/ui/code_editor.rs | 13 +++++++-- src/ui/file_tree.rs | 15 ++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 34b43e4b..6f6aa884 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -690,19 +690,27 @@ impl Tty7App { pub(crate) fn scm_sync_watchers(&mut self, window: &Window, cx: &mut Context) { self.scm_forget_lost_hosts(cx); - let target = self.scm_panel_target(window, 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 target.is_none() && cx.try_global::().is_none_or(ScmData::is_quiet) { + if wanted.iter().all(|(_, t)| t.is_none()) + && cx.try_global::().is_none_or(ScmData::is_quiet) + { return; } - let dropped = cx - .default_global::() - .subscriptions() - .declare(ScmWatcher::Panel, target); - if let Some((host, root)) = dropped { - cx.default_global::().drop_watch(host, &root); + 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() { @@ -737,6 +745,44 @@ impl Tty7App { 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 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1106879f..d02adc0a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2647,6 +2647,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); @@ -2655,6 +2658,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/code_editor.rs b/src/ui/code_editor.rs index 4f3fbd9b..70227bb0 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -603,6 +603,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, @@ -619,16 +621,21 @@ 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; - } + Ok(mtime) => f.disk_mtime = mtime, Err(e) => HostOps::notify_err(window, cx, t(L10nKey::EditorSaveFailed), &e), } if landing.clean { 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/file_tree.rs b/src/ui/file_tree.rs index e2094794..f6256604 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -731,6 +731,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")); From 293d761cba3857b7c905404cebea009bbc826b6a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:02:43 +0800 Subject: [PATCH 22/36] feat(scm): show the branch, its distance and one button to close it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row of its own between the title and the message box. Not the title's trailing slot: off macOS the tab tiles render after it, and a branch name is the elastic element here — it would be the first thing squeezed. A branch level with its upstream says nothing at all. The quiet state is the common one, and a chip that is always there stops being read; ahead, behind and "publish" are the three things worth interrupting for, plus the sequencer operation the repository is parked in and the amend badge. Interactive and plain rebase read the same, because git writes `rebase-merge/interactive` for every rebase and the distinction is not one the repository on disk can make. The branch name opens the switcher, which lists the local branches from `for-each-ref`, re-read whenever anything could have moved a ref. Naming a new branch is an inline input rather than a dialog: `window.prompt` only offers buttons, and there is no modal component to reach for. The root every operation runs from is now 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 — the panel would have sat on "Loading…" forever without one. Two render-idle tests hold the panel to asking git once and then going quiet, over a real repository and over a directory that is not one. The hazard they cover is specific: `scm_refresh` reaches for its cache through `default_global` from inside `render`, so a watcher that notified on every global write would ask for a frame from inside a frame forever. --- src/ui/scm/actions.rs | 16 +- src/ui/scm/panel.rs | 740 ++++++++++++++++++++++++++++++++++++++++-- src/ui/scm/state.rs | 23 +- 3 files changed, 737 insertions(+), 42 deletions(-) diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 0a739612..516285c3 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -80,6 +80,15 @@ impl Tty7App { let Some(host) = HostRegistry::get(cx, repo.host) else { return; }; + if op.is_network() { + // The epoch `run_git_op` bumps when it lands is the only signal + // there is that a push has finished, so record the one we started + // from and let the branch row spin until it moves. + let at = cx + .default_global::() + .epoch(repo.host, &repo.root); + self.scm.network = Some((repo.clone(), at)); + } let Some(loss) = op.destructive() else { self.run_git_op(host, repo.root, op, window, cx); return; @@ -148,9 +157,10 @@ impl Tty7App { window, cx, ), - // Both open a picker rather than doing anything, so they are the - // panel's business and not this match's. - ScmIntent::CheckoutBranch | ScmIntent::CreateBranch => {} + 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 => {} } } diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index fec392f2..d1c7f237 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -21,7 +21,7 @@ use gpui_component::{ use tty7_core::core::git::diff::MAX_RENDERED_FILES; use tty7_core::core::git::ops::GitOp; use tty7_core::core::git::status::{ - ChangeCode, DecoStatus, RepoPath, StatusEntry, WorkingTreeStatus, + ChangeCode, DecoStatus, HeadState, RepoOperation, RepoPath, StatusEntry, WorkingTreeStatus, }; use crate::terminal::git_data::status_of; @@ -29,9 +29,9 @@ use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::host_ops::{HostId, SharedHost}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; -use crate::ui::right_panel::git_badge; +use crate::ui::right_panel::{git_badge, info_chip}; use crate::ui::scm::ScmIntent; -use crate::ui::scm::path::split_display_path; +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}; @@ -60,6 +60,13 @@ pub(crate) const TILE_GLYPH_XS: f32 = 11.; /// 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. @@ -74,6 +81,11 @@ const UNTRACKED_AUTO_COLLAPSE: usize = 20; /// 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. @@ -89,6 +101,12 @@ impl Tty7App { 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); @@ -118,24 +136,39 @@ impl Tty7App { RepoLookup::Root(root) => root, }; - self.scm_probe(&host, &root, cx); - let Some(status) = self.scm_seen_status(host.id(), &root, cx) else { + self.scm.repo = Some(RepoKey { + host: host.id(), + root, + }); + // 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 repo = RepoKey { - host: host.id(), - root, - }; - self.scm.repo = Some(repo.clone()); - 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); let body = if status.is_clean() { self.panel_empty( t(L10nKey::PanelNoChanges), @@ -145,7 +178,368 @@ impl Tty7App { } else { self.scm_groups(&repo, &status, cx) }; - self.scm_shell_with(title, vec![commit, buttons], body) + self.scm_shell_with(title, pinned, body) + } + + /// 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 (accent, warning, muted, fg) = ( + theme.accent, + 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.)) + .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 chips. + .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| info_chip(&format!("+{count}"), accent, 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, + ) + })) + .when(self.scm.amend, |this| { + this.child(info_chip(t(L10nKey::ScmAmendBadge), accent, muted, &mono)) + }) + .children( + tracking_chip(status.upstream.as_deref(), status.ahead_behind) + .map(|text| info_chip(&text, accent, 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 dispatched from here is still running. + /// + /// There is no completion callback to hang this off, but `run_git_op` + /// bumps the repository's epoch when it lands — so an epoch that has not + /// moved since the dispatch means the operation has not finished. + fn scm_network_busy(&self, repo: &RepoKey, cx: &mut Context) -> bool { + let Some((sent, at)) = &self.scm.network else { + return false; + }; + sent == repo + && cx + .default_global::() + .epoch(repo.host, &repo.root) + == *at + } + + /// 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() + .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. @@ -413,9 +807,11 @@ impl Tty7App { /// root is also the cache key, which is what lets two panes in two /// subdirectories of one repository share a single status. /// - /// The cheap repository/not-a-repository answer comes from the cache the - /// tab badge already fills in, so a directory that is not a repository - /// never reaches `git status` from here at all. + /// 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, @@ -424,27 +820,44 @@ impl Tty7App { ) -> RepoLookup { let id = host.id(); let key = (id, cwd.to_path_buf()); - if let Some(root) = self.scm.roots.get(&key) { - return RepoLookup::Root(root.clone()); + 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, + _ => {} } - match cx - .try_global::() - .and_then(|cache| cache.known_repo_for(id, cwd)) - { - None => RepoLookup::Pending, - Some(None) => RepoLookup::NotARepo, - Some(Some(_)) => { - self.scm_probe(host, cwd, cx); - match status_of(cx, id, cwd) { - Some(status) => { - let root = status.root.clone(); - self.scm.roots.insert(key, root.clone()); - RepoLookup::Root(root) - } - None => RepoLookup::Pending, - } - } + 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. @@ -1097,6 +1510,53 @@ 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 ↓1` chip, or nothing. +/// +/// A branch that is level with its upstream says nothing at all: the quiet +/// state is the common one, and a chip that is always there stops being read. +/// A branch with no upstream offers to publish instead. +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, 0) => Some(format!("↑{ahead}")), + (0, behind) => Some(format!("↓{behind}")), + (ahead, behind) => Some(format!("↑{ahead} ↓{behind}")), + } +} + +/// 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, @@ -1231,7 +1691,7 @@ mod tests { 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, HeadState, RepoPath}; + use tty7_core::core::git::status::{ConflictKind, EntryKind, RepoPath}; fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry { StatusEntry { @@ -1557,6 +2017,70 @@ mod tests { 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); + assert_eq!( + tracking_chip(Some("origin/main"), Some((2, 0))).as_deref(), + Some("↑2") + ); + assert_eq!( + tracking_chip(Some("origin/main"), Some((0, 1))).as_deref(), + Some("↓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(); @@ -1763,3 +2287,149 @@ mod tests { })); } } + +/// 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); + } +} diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 4a764fe4..fe8961a1 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -59,6 +59,19 @@ pub(crate) struct ScmPanelState { /// 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, + /// A network operation in flight, and the epoch it was dispatched at. + /// `run_git_op` bumps the epoch when it finishes, which is the only + /// completion signal available from outside it. + pub(crate) network: Option<(RepoKey, u64)>, + /// 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 @@ -81,10 +94,12 @@ pub(crate) struct ScmPanelState { /// 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. Cached because - /// the root is what every write and every cache lookup is keyed by, and - /// only a `git status` can say what it is. - pub(crate) roots: HashMap<(HostId, PathBuf), PathBuf>, + /// 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 From 16d7c1b5a263b6e6e2b8f85fddb9e4d8adf1995e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:10:53 +0800 Subject: [PATCH 23/36] refactor(scm): retire the panel's old flat-diff state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel now reads WorkingTreeStatus, so RightPanelState's diff, diff_cwd and diff_pending had no writer left — and with them went PANEL_DIFF_SOURCE, the seed shortcut that borrowed the panel's snapshot, and the write-back that kept it fed. The overlay always names its own source now. Two things were still reading that state and quietly getting nothing: - the source control tile's badge, which now counts entries from the same status the panel draws, so the number and the group headers cannot disagree - the branch row's busy spinner, which was inferring "the push finished" from the repository's epoch moving. Anything else that moves the epoch — a .git event, a file save — would have dropped the spinner mid-push. It asks the slot counter run_git_op claims from instead, which is the operation itself. scm_network_busy reads through try_global rather than default_global: it runs from render, where taking the global mutably queues a global-observer effect on every frame. --- src/ui/diff_overlay.rs | 53 ++++-------------------------------------- src/ui/right_panel.rs | 5 ---- src/ui/scm/actions.rs | 14 ++++------- src/ui/scm/panel.rs | 33 +++++++++++--------------- src/ui/scm/state.rs | 4 ---- src/ui/tab_strip.rs | 18 ++++++++------ 6 files changed, 32 insertions(+), 95 deletions(-) diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index eb914b4e..3dc13f15 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -23,18 +23,6 @@ use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; use crate::ui::scm::status::{status_color, status_glyph}; -/// What the right panel's shared probe asks git for, and so what an overlay -/// opened from the sidebar shows. -/// -/// The panel's own contract, not a default the rest of the file leans on: it -/// is read where the panel's request is issued, where the panel's answer is -/// filed away, and at the one entry point that has no source of its own to -/// name. Whether an overlay may reuse the panel's snapshot is settled by that -/// snapshot's own `source`, and whether an overlay has gone stale by the -/// overlay's — so when the panel splits into staged and unstaged groups, this -/// constant is the only thing that has to move. -const PANEL_DIFF_SOURCE: DiffSource = DiffSource::Head; - pub(crate) enum DiffLoad { Loading, Ready(Arc), @@ -101,7 +89,9 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - self.open_diff_overlay(host, cwd, PANEL_DIFF_SOURCE, focus, window, cx) + // 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( @@ -142,18 +132,7 @@ impl Tty7App { } None => {} } - // Skipping the "Reading…" flash is only allowed when the panel's - // snapshot answers the same question this overlay is asking. The - // snapshot says which question that was, so this stays right through - // whatever the panel decides to probe for next. - let seed = match (&self.right_panel.diff_cwd, &self.right_panel.diff) { - (Some(panel_key), Some(Some(snap))) - if snap.source == source && *panel_key == (host, cwd.clone()) => - { - DiffLoad::Ready(Arc::clone(snap)) - } - _ => DiffLoad::Loading, - }; + let seed = DiffLoad::Loading; let epoch = match &seed { DiffLoad::Ready(snap) => Some(scm_epoch(cx, host, &snap.root)), _ => None, @@ -233,15 +212,6 @@ impl Tty7App { self.spawn_diff_probe_for(host, cwd, source, cx); } - pub(crate) fn spawn_shared_diff_probe( - &mut self, - host: crate::ui::host_ops::SharedHost, - cwd: PathBuf, - cx: &mut Context, - ) { - self.spawn_diff_probe_for(host, cwd, PANEL_DIFF_SOURCE, cx) - } - pub(crate) fn spawn_diff_probe_for( &mut self, host: crate::ui::host_ops::SharedHost, @@ -306,21 +276,6 @@ impl Tty7App { }; landed = true; } - if *source != PANEL_DIFF_SOURCE { - if landed { - cx.notify(); - } - return; - } - 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); - landed = true; - } if landed { cx.notify(); } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 7427aa4d..f5e36ec0 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -5,11 +5,9 @@ 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; use crate::ui::app::{ CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset, tile_trailing_inset_sm, @@ -24,9 +22,6 @@ const RESIZE_HANDLE_WIDTH: f32 = 8.; #[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, diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 516285c3..e4b23978 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -80,15 +80,6 @@ impl Tty7App { let Some(host) = HostRegistry::get(cx, repo.host) else { return; }; - if op.is_network() { - // The epoch `run_git_op` bumps when it lands is the only signal - // there is that a push has finished, so record the one we started - // from and let the branch row spin until it moves. - let at = cx - .default_global::() - .epoch(repo.host, &repo.root); - self.scm.network = Some((repo.clone(), at)); - } let Some(loss) = op.destructive() else { self.run_git_op(host, repo.root, op, window, cx); return; @@ -119,7 +110,10 @@ impl Tty7App { return; }; match intent { - ScmIntent::Refresh => self.scm_invalidate(&repo, cx), + 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), diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index d1c7f237..56b0eecf 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -291,20 +291,19 @@ impl Tty7App { .into_any_element() } - /// Whether a network operation dispatched from here is still running. + /// Whether a network operation against this repository is still running. /// - /// There is no completion callback to hang this off, but `run_git_op` - /// bumps the repository's epoch when it lands — so an epoch that has not - /// moved since the dispatch means the operation has not finished. - fn scm_network_busy(&self, repo: &RepoKey, cx: &mut Context) -> bool { - let Some((sent, at)) = &self.scm.network else { - return false; - }; - sent == repo - && cx - .default_global::() - .epoch(repo.host, &repo.root) - == *at + /// 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. @@ -1407,13 +1406,7 @@ impl Tty7App { return; } self.scm.last_tab_status = Some(seen); - self.scm_invalidate(&repo, cx); - } - - /// Send the next look at a repository back to git. - pub(crate) fn scm_invalidate(&mut self, repo: &RepoKey, cx: &mut Context) { - cx.default_global::() - .bump(repo.host, &repo.root); + self.scm_invalidate(repo.host, &repo.root, cx); cx.notify(); } diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index fe8961a1..ac11544c 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -66,10 +66,6 @@ pub(crate) struct ScmPanelState { /// the switcher is never older than the last operation. pub(crate) branches: HashMap)>, pub(crate) branches_loading: HashSet, - /// A network operation in flight, and the epoch it was dispatched at. - /// `run_git_op` bumps the epoch when it finishes, which is the only - /// completion signal available from outside it. - pub(crate) network: Option<(RepoKey, u64)>, /// The inline "name your branch" input, present only while it is open. pub(crate) new_branch: Option>, /// Unsent commit messages, one per working tree. diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index f033823c..c09f2473 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -390,13 +390,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, From d8d822092d85fd1829d7dde04c99c07b1df66db1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:43:15 +0800 Subject: [PATCH 24/36] chore(scm): reserve the graph and commit-detail mount points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both land in panel.rs — the history section below the file list, the detail body in place of it — and both are being written in parallel. Landing the two call sites and their stub modules up front keeps them from colliding over the same function. The detail body replaces the working tree's rather than sitting beside it, so the two can never be on screen each claiming to be the file list. The history section sits outside the scroller: it pages, and sharing a scroll region would mean scrolling back past hundreds of commits to reach the message box. --- src/ui/scm/detail.rs | 27 +++++++++++++++++++++++++++ src/ui/scm/graph.rs | 32 ++++++++++++++++++++++++++++++++ src/ui/scm/mod.rs | 4 ++++ src/ui/scm/panel.rs | 31 +++++++++++++++++++++++++------ src/ui/scm/state.rs | 1 + 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/ui/scm/detail.rs create mode 100644 src/ui/scm/graph.rs diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs new file mode 100644 index 00000000..ea6c972a --- /dev/null +++ b/src/ui/scm/detail.rs @@ -0,0 +1,27 @@ +//! 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. + +use gpui::{AnyElement, Context}; + +use crate::ui::app::Tty7App; +use crate::ui::scm::state::CommitDetailView; + +impl Tty7App { + /// The commit detail body, shown in place of the file groups. + pub(crate) fn render_commit_detail( + &mut self, + _detail: &CommitDetailView, + _window: &mut gpui::Window, + _cx: &mut Context, + ) -> Option { + None + } +} diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs new file mode 100644 index 00000000..93384dfd --- /dev/null +++ b/src/ui/scm/graph.rs @@ -0,0 +1,32 @@ +//! The history section at the foot of the panel. +//! +//! Its job is shape, not text. 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 +//! message is the commit detail view's job, one click away. +//! +//! That is also VS Code's own reading of a sidebar graph, and it is why the +//! conventional-commit prefix is lifted out into a chip rather than left to +//! eat half the line. + +use gpui::{AnyElement, Context}; + +use crate::ui::app::Tty7App; +use crate::ui::scm::state::RepoKey; + +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 gpui::Window, + _cx: &mut Context, + ) -> Option { + None + } +} diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index fa2944c1..ff6da7c7 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -8,6 +8,10 @@ // `relative_time` has no row to date yet, and `status_rank` is the file tree's // to use. Both allows come off with the step that wires them up. pub(crate) mod actions; +#[allow(dead_code)] +pub(crate) mod detail; +#[allow(dead_code)] +pub(crate) mod graph; pub(crate) mod panel; #[allow(dead_code)] pub(crate) mod path; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 56b0eecf..b1c331a0 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -169,16 +169,23 @@ impl Tty7App { pinned.extend(naming); pinned.push(commit); pinned.push(buttons); - let body = if status.is_clean() { - self.panel_empty( + // 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, - ) - } else { - self.scm_groups(&repo, &status, cx) + ), + None => self.scm_groups(&repo, &status, cx), }; - self.scm_shell_with(title, pinned, body) + 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 @@ -762,6 +769,17 @@ impl Tty7App { 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. + fn scm_shell_full( + &self, + title: AnyElement, + pinned: Vec, + body: AnyElement, + footer: Option, ) -> AnyElement { let scroller = div() .id("panel-scm-body") @@ -780,6 +798,7 @@ impl Tty7App { scroller, &self.scm.scroll, )) + .children(footer) .into_any_element() } diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index ac11544c..96880939 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -174,6 +174,7 @@ pub(crate) struct GraphState { /// touched. A file-level diff is not shown here — that opens the full-screen /// overlay, because 260px cannot render a diff and pretending otherwise would /// mean inventing a third kind of container. +#[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct CommitDetailView { pub(crate) repo: RepoKey, pub(crate) oid: String, From 60b4ebacfda4c08eb007b14f0323c0179e9b136f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:08:57 +0800 Subject: [PATCH 25/36] =?UTF-8?q?spike(scm):=20prove=20the=20graph's=20one?= =?UTF-8?q?-canvas=20rendering=20plan=20(G7=C2=B70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard-coded three-lane figure, no data layer attached, standing in for the history section. It exists to answer the four questions the whole rendering plan rests on before any of it is built on top, because a "no" to any of them invalidates the rest. Verified against an isolated dev instance (own config dir and daemon), screenshots read back: 1. One `canvas` absolutely positioned over the rows, inside the section's own `overflow_y_scroll`, paints where it should — lines and nodes land on their rows and stay on them after scrolling. A canvas per row was never on the table: every `Paths` batch costs a full-drawable render pass. 2. `window.content_mask().bounds` gives usable culling bounds. With 33 rows and a 195px viewport it reported `705..900` and the paint loop covered rows 0..10 — 24 quads instead of ~70. Scrolling moved that to rows 16..26 while the mask stayed put, which is exactly right: the mask is the viewport, the canvas bounds are what moves. 3. Clicks reach the row `div` through the canvas. A click landed inside the lane gutter, on top of a painted line, and selected the row underneath. `Canvas::id` returns `None` and it implements no interactivity, so it never registers a hitbox — being above in z only decides paint order. That is what buys the rows gpui's native hover, click and scroll-into-view for free. 4. The height divider, which is `right_panel_resize` rotated onto the other axis, tracks the cursor 1:1 and clamps where it is told to. Everything is drawn with `paint_quad`: straight segments as thin rects, nodes as rounded quads for the SDF's analytic anti-aliasing, and cross-lane turns as right-angle elbows. The elbows read cleanly at a 12px lane pitch — tig, lazygit and `git log --graph` all draw them square — so curves stay behind a switch rather than in the first version. Lane centres are snapped to device pixels before the quad is built, not after, or a column of lines changes width as it scrolls. The next commit replaces the fake rows with `CommitPage` and keeps this shape. --- src/ui/scm/graph.rs | 377 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 372 insertions(+), 5 deletions(-) diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 93384dfd..4bf0b334 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -9,12 +9,77 @@ //! That is also VS Code's own reading of a sidebar graph, and it is why the //! conventional-commit prefix is lifted out into a chip rather than left to //! eat half the line. +//! +//! # Spike (G7·0) +//! +//! What is below is deliberately a hard-coded three-row figure. It exists to +//! prove the four load-bearing claims of the rendering plan before any of the +//! real data is wired to it: that one absolutely-positioned canvas draws +//! correctly inside a scroll container, that `content_mask` gives usable +//! culling bounds, that the row `div`s underneath still receive hover and +//! click through the canvas above them, and that the height drag feels right. +//! The next commit replaces the fake rows with `CommitPage` and keeps the +//! shape. -use gpui::{AnyElement, Context}; +use std::cell::Cell as StdCell; +use std::rc::Rc; -use crate::ui::app::Tty7App; +use gpui::{ + AnyElement, Bounds, Context, Corners, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, + SharedString, Window, canvas, div, fill, prelude::*, px, +}; +use gpui_component::{ActiveTheme as _, h_flex, v_flex}; + +use crate::ui::app::{CONTENT_INSET, Tty7App}; 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 lane geometry reads better when the vertical pitch is +/// close to the lane pitch. +const GRAPH_ROW_H: f32 = 20.; + +/// Horizontal distance between lane centres. +const GRAPH_LANE_W: f32 = 12.; + +/// Inset before the first lane centre, so lane 0 is not flush against the +/// panel edge. +const GRAPH_PAD_L: f32 = 6.; + +const GRAPH_DOT_R: f32 = 3.; +const GRAPH_LINE_W: f32 = 1.5; + +/// Resting height of the history section, and the range the divider drags it +/// through. The maximum is a fraction of the panel rather than a constant: +/// the file list has to keep a usable share of a short window. +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.; + +/// Snap a lane centre to a device pixel *before* the quad is built. +/// +/// `paint_quad` snaps the bounds it is given, but it does that to 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 reasoning, same shape as `powerline_solid_edge`. +fn snap(x: f32, scale: f32) -> f32 { + if !scale.is_finite() || scale <= 0. { + return x; + } + (x * scale).round() / scale +} + +/// Centre of `lane`, in the canvas's own coordinate space. +fn lane_center_x(lane: u16, scale: f32) -> f32 { + snap( + GRAPH_PAD_L + GRAPH_LANE_W * lane as f32 + GRAPH_LANE_W / 2., + scale, + ) +} + impl Tty7App { /// The history section, when it is expanded and has something to draw. /// @@ -24,9 +89,311 @@ impl Tty7App { pub(crate) fn render_graph_section( &mut self, _repo: &RepoKey, - _window: &mut gpui::Window, - _cx: &mut Context, + window: &mut Window, + cx: &mut Context, ) -> Option { - None + if !self.scm.graph.expanded { + return Some(self.graph_header(cx)); + } + if self.scm.graph.height.get() <= 0. { + self.scm.graph.height.set(GRAPH_H_DEFAULT); + } + let max = (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, max); + + // The spike's figure: a straight lane 0, a branch that opens on row 1 + // and merges back on row 2. Three dots, one elbow each way. + let mut rows: Vec<(u16, &'static str)> = vec![ + (0, "third commit on the trunk"), + (1, "a branch opens here"), + (0, "and merges back in"), + ]; + // Enough rows that the section actually scrolls, which is the only way + // to watch the culling window track the viewport. + for _ in 0..30 { + rows.push((0, "filler so the section scrolls")); + } + let lanes = 2u16; + let gutter = GRAPH_PAD_L + GRAPH_LANE_W * lanes as f32; + let scale = window.scale_factor(); + let line = cx.theme().accent; + let alt = cx.theme().warning; + let sf = cx.theme().secondary; + let fg = cx.theme().foreground; + + let painted = Rc::new(StdCell::new(0usize)); + let seen_mask = Rc::new(StdCell::new(0.0f32)); + let clicks = self.scm.graph.selected.clone(); + + let body = div() + .relative() + .w_full() + .h(px(rows.len() as f32 * GRAPH_ROW_H)) + .child( + v_flex().children( + rows.iter() + .enumerate() + .map(|(i, (_, text))| self.graph_spike_row(i, text, cx)), + ), + ) + .child( + canvas(|_, _, _| (), { + let rows = rows.clone(); + let painted = painted.clone(); + let seen_mask = seen_mask.clone(); + move |bounds: Bounds, _, window: &mut Window, _| { + // Culling: the canvas is as tall as the whole + // content, so only the band the mask allows is + // worth iterating. + let mask = window.content_mask().bounds; + seen_mask.set(mask.size.height.as_f32()); + let top = bounds.origin.y.as_f32(); + 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 mut n = 0usize; + for (i, (lane, _)) in rows.iter().enumerate().skip(first).take(last - first) + { + let y0 = top + i as f32 * GRAPH_ROW_H; + let mid = y0 + GRAPH_ROW_H / 2.; + let cx0 = bounds.origin.x.as_f32() + lane_center_x(0, scale); + let cx1 = bounds.origin.x.as_f32() + lane_center_x(1, scale); + let c = if *lane == 0 { line } else { alt }; + // Lane 0 runs the full height of every band. + window.paint_quad(fill( + Bounds::from_corners( + gpui::point(px(cx0 - GRAPH_LINE_W / 2.), px(y0)), + gpui::point(px(cx0 + GRAPH_LINE_W / 2.), px(y0 + GRAPH_ROW_H)), + ), + line, + )); + n += 1; + // Row 1 opens lane 1 with an elbow, row 2 + // closes it with the mirror image. + if i == 1 { + window.paint_quad(fill( + Bounds::from_corners( + gpui::point(px(cx0), px(mid - GRAPH_LINE_W / 2.)), + gpui::point(px(cx1), px(mid + GRAPH_LINE_W / 2.)), + ), + alt, + )); + window.paint_quad(fill( + Bounds::from_corners( + gpui::point(px(cx1 - GRAPH_LINE_W / 2.), px(mid)), + gpui::point( + px(cx1 + GRAPH_LINE_W / 2.), + px(y0 + GRAPH_ROW_H), + ), + ), + alt, + )); + n += 2; + } + if i == 2 { + window.paint_quad(fill( + Bounds::from_corners( + gpui::point(px(cx1 - GRAPH_LINE_W / 2.), px(y0)), + gpui::point(px(cx1 + GRAPH_LINE_W / 2.), px(mid)), + ), + alt, + )); + window.paint_quad(fill( + Bounds::from_corners( + gpui::point(px(cx0), px(mid - GRAPH_LINE_W / 2.)), + gpui::point(px(cx1), px(mid + GRAPH_LINE_W / 2.)), + ), + alt, + )); + n += 2; + } + // The node. A rounded quad, not a path: quads + // get the SDF's analytic anti-aliasing, and a + // path would open a whole render pass. + let cxn = if *lane == 0 { cx0 } else { cx1 }; + window.paint_quad( + fill( + Bounds::from_corners( + gpui::point(px(cxn - GRAPH_DOT_R), px(mid - GRAPH_DOT_R)), + gpui::point(px(cxn + GRAPH_DOT_R), px(mid + GRAPH_DOT_R)), + ), + c, + ) + .corner_radii(Corners::all(px(GRAPH_DOT_R))), + ); + n += 1; + } + painted.set(n); + } + }) + .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(body); + + let (backing, handle) = self.graph_resize(max, cx); + let _ = clicks; + Some( + v_flex() + .relative() + .flex_none() + .h(px(height)) + .border_t_1() + .border_color(cx.theme().border) + .bg(sf) + .text_color(fg) + .child(backing) + .child(self.graph_header(cx)) + .child(scroller) + .child(handle) + .into_any_element(), + ) + } + + fn graph_header(&self, cx: &mut Context) -> AnyElement { + let expanded = self.scm.graph.expanded; + h_flex() + .id("scm-graph-header") + .flex_none() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .px(px(CONTENT_INSET)) + .cursor_pointer() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(if expanded { + "▾ Graph (spike)" + } else { + "▸ Graph (spike)" + })) + .on_click(cx.listener(|this, _, _, cx| this.scm_toggle_graph(cx))) + .into_any_element() + } + + /// One interactive row. Deliberately an ordinary `div`: `Canvas::id` + /// returns `None` and it implements no interactivity, so it registers no + /// hitbox in prepaint — being drawn on top of these rows changes the + /// painting order and nothing about where a click lands. + fn graph_spike_row(&self, i: usize, text: &str, cx: &mut Context) -> AnyElement { + let sf = cx.theme().secondary; + let id = spike_id(i); + let selected = self.scm.graph.selected.as_deref() == Some(id.as_str()); + h_flex() + .id(SharedString::from(format!("scm-graph-row-{i}"))) + .items_center() + .h(px(GRAPH_ROW_H)) + .pl(px(GRAPH_PAD_L + GRAPH_LANE_W * 2. + 8.)) + .pr(px(CONTENT_INSET)) + .text_size(px(12.)) + .when(selected, |d| d.bg(cx.theme().accent.opacity(0.28))) + .when(!selected, |d| d.hover(|s| s.bg(sf.opacity(0.9)))) + .cursor_pointer() + .child(SharedString::from(text.to_string())) + .on_click(cx.listener(move |this, _, _, cx| { + this.scm.graph.selected = Some(spike_id(i)); + cx.notify(); + })) + .into_any_element() + } + + /// `right_panel_resize` rotated 90°: the same canvas-remembers-bounds plus + /// `Rc` pair, dragging the top edge of the history section instead of + /// the left edge of the panel. + fn graph_resize(&self, max: 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; + }; + let bottom = b.origin.y + b.size.height; + let raw = (bottom - ev.position.y).as_f32(); + height.set(raw.clamp(GRAPH_H_MIN, max)); + 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() + .justify_center() + .cursor_row_resize() + .child( + div() + .h(px(1.)) + .w_full() + .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) } } + +/// Stand-in for the sha the real rows will be keyed by. +fn spike_id(i: usize) -> String { + format!("spike-{i}") +} From 5497252aec1a4f43c1d294503136d8a6f083d5d8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:20:34 +0800 Subject: [PATCH 26/36] feat(scm): read a whole commit in the panel's second-level view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph gives up text for shape: a 260px row has room for about 26 characters beside its lanes, and this repository's commit subjects run to a median of 64. This is where the rest comes back — the full subject, the body, every ref, the parents, and the files the commit touched. The file list is two commands, not one. Git accepts `--numstat` and `--name-status` together and then quietly drops the numstat half (measured on 2.50.1), so they run separately and join on the path. It is `log -1 --first-parent` rather than `diff-tree -m --first-parent`, which does not narrow a merge: on the same git it emits one diff per parent and concatenates them, so a two-parent merge came back with every path listed twice. `log` is also how `DiffSource::Commit` walks the patch, which is what makes this list and the overlay's cards agree file for file. `DiffSource::Commit` now carries an optional label, so the overlay's header can say what the commit was about instead of eight hex digits. The label deliberately takes no part in the source's identity: `PartialEq`, `Hash` and the overlay's string probe key are all derived from one `tag()` function, because the same commit opened with a subject in hand and without one has to stay one patch, one in-flight probe and one overlay. The key used to be built from `Debug`, which would have split the probe cache the moment a label arrived. Also lifts `local_branches` out of the panel's inline `for-each-ref`, and keeps the `%(upstream)` that `parse_refs` had been asking for and throwing away. --- crates/tty7-core/src/core/git/diff.rs | 150 ++++- crates/tty7-core/src/core/git/log.rs | 468 +++++++++++++- src/ui/diff_overlay.rs | 182 +++++- src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 10 +- src/ui/i18n/zh.rs | 4 + src/ui/scm/detail.rs | 885 +++++++++++++++++++++++++- src/ui/scm/mod.rs | 7 +- src/ui/scm/state.rs | 40 ++ 10 files changed, 1709 insertions(+), 45 deletions(-) diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 209c2991..01761bd0 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -28,12 +28,29 @@ pub enum Truncation { Budget, } +/// What a commit is called, for a header that would otherwise have eight hex +/// digits and nothing else to say. +/// +/// Rides along on [`DiffSource::Commit`] and takes no part in its identity — +/// see [`DiffSource::tag`]. Defined here rather than in [`log`](super::log) so +/// the dependency between the two modules stays one-way: `log` reaches into +/// `diff` for [`FileStatus`], and nothing goes back. That is also why the +/// timestamp is a bare unix second rather than an +/// [`OffsetTs`](super::log::OffsetTs) — relative time is all the header shows. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct CommitLabel { + pub subject: String, + pub author: String, + /// Author time, unix seconds. Zero where it is not known. + pub at: i64, +} + /// Which patch to ask git for. /// /// The three working-tree variants are the same three questions the SCM panel /// asks — `Worktree` is what is not staged, `Staged` is what is, and `Head` is /// both at once, which is what the overlay has always shown. -#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] +#[derive(Clone, Debug, Default)] pub enum DiffSource { /// `git diff` — unstaged changes. Worktree, @@ -42,13 +59,59 @@ pub enum DiffSource { /// `git diff HEAD` — staged and unstaged together. #[default] Head, - /// One commit against its first parent. - Commit { rev: String }, + /// One commit against its first parent, and optionally what to call it. + Commit { + rev: String, + label: Option, + }, /// `base...head`: what `head` added since the two diverged. Range { base: String, head: String }, } +impl PartialEq for DiffSource { + fn eq(&self, other: &DiffSource) -> bool { + self.tag() == other.tag() + } +} + +impl Eq for DiffSource {} + +impl std::hash::Hash for DiffSource { + fn hash(&self, state: &mut H) { + self.tag().hash(state); + } +} + impl DiffSource { + /// A commit with nothing known about it yet beyond which one it is. + pub fn commit(rev: impl Into) -> DiffSource { + DiffSource::Commit { + rev: rev.into(), + label: None, + } + } + + /// The identity of the patch, with nothing on it that only affects how the + /// patch is *labelled*. + /// + /// `PartialEq`, `Hash` and the overlay's string cache key are all defined + /// from this one function, so the three cannot drift apart. The rule it + /// exists to enforce: the same commit opened from the graph (with a + /// subject in hand) and from a keybinding (without one) is one patch, one + /// in-flight probe and one overlay. A derived `PartialEq` would make them + /// two, and the derived `Debug` the overlay's key used to be built from + /// would have split the probe cache the same way. + pub fn tag(&self) -> String { + match self { + DiffSource::Worktree => "worktree".to_string(), + DiffSource::Staged => "staged".to_string(), + DiffSource::Head => "head".to_string(), + // US, which can occur in neither a refname nor an object id. + DiffSource::Commit { rev, .. } => format!("commit\u{1f}{rev}"), + DiffSource::Range { base, head } => format!("range\u{1f}{base}\u{1f}{head}"), + } + } + /// The whole argv, minus pathspecs. Every diff tty7 runs is built here so /// there is one place to read, and one place to test, what git is asked. pub fn args(&self, context: u32, ignore_whitespace: bool) -> Vec { @@ -70,7 +133,7 @@ impl DiffSource { // is no special case and no need for `--root`. `--format=` empties // the commit header, at the cost of one blank line the parser // ignores. - DiffSource::Commit { rev } => { + DiffSource::Commit { rev, .. } => { argv.extend(strings(&["log", "-p", "-1", "--format=", "--first-parent"])); argv.push(rev.clone()); } @@ -767,9 +830,7 @@ Binary files a/img.png and b/img.png differ (DiffSource::Staged, vec!["diff", "--cached"]), (DiffSource::Head, vec!["diff", "HEAD"]), ( - DiffSource::Commit { - rev: "deadbeef".into(), - }, + DiffSource::commit("deadbeef"), vec!["log", "-p", "-1", "--format=", "--first-parent", "deadbeef"], ), ( @@ -791,6 +852,73 @@ Binary files a/img.png and b/img.png differ } } + /// The one property the whole label mechanism rests on. Break it and the + /// same commit becomes two probes, two overlays and two cache entries the + /// moment one of them learns its own subject. + #[test] + fn a_commits_label_is_not_part_of_which_commit_it_is() { + let bare = DiffSource::commit("deadbeef"); + let labelled = DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel { + subject: "fix(scm): the thing".into(), + author: "Ada".into(), + at: 1_786_255_391, + }), + }; + let other = DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel { + subject: "something else entirely".into(), + ..Default::default() + }), + }; + + assert_eq!(bare, labelled); + assert_eq!(labelled, other); + assert_eq!(bare.tag(), labelled.tag()); + assert_eq!(hash_of(&bare), hash_of(&labelled)); + assert_eq!(hash_of(&labelled), hash_of(&other)); + // …and the argv, which is the other thing a split cache would show up + // in: two "different" sources running the identical command. + assert_eq!(argv(bare.clone()), argv(labelled)); + + // Which commit it is still separates them, of course. + let elsewhere = DiffSource::commit("cafebabe"); + assert_ne!(bare, elsewhere); + assert_ne!(hash_of(&bare), hash_of(&elsewhere)); + assert_ne!(bare, DiffSource::Head); + assert_ne!( + DiffSource::Range { + base: "a".into(), + head: "b".into() + }, + DiffSource::Range { + base: "b".into(), + head: "a".into() + } + ); + // Every variant is still equal to itself, which `Eq` promises and a + // hand-written `PartialEq` is exactly where it could stop being true. + for source in [ + DiffSource::Worktree, + DiffSource::Staged, + DiffSource::Head, + bare, + elsewhere, + ] { + assert_eq!(source, source.clone(), "{source:?}"); + assert_eq!(hash_of(&source), hash_of(&source.clone())); + } + } + + fn hash_of(source: &DiffSource) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + source.hash(&mut hasher); + hasher.finish() + } + #[test] fn quote_path_is_off_on_every_source() { // Left on, a non-ASCII path arrives as C octal escapes that nothing @@ -802,7 +930,7 @@ Binary files a/img.png and b/img.png differ DiffSource::Worktree, DiffSource::Staged, DiffSource::Head, - DiffSource::Commit { rev: "HEAD".into() }, + DiffSource::commit("HEAD"), DiffSource::Range { base: "a".into(), head: "b".into(), @@ -915,7 +1043,7 @@ Binary files a/img.png and b/img.png differ assert!(DiffSource::Worktree.lists_untracked()); assert!(DiffSource::Head.lists_untracked()); assert!(!DiffSource::Staged.lists_untracked()); - assert!(!DiffSource::Commit { rev: "x".into() }.lists_untracked()); + assert!(!DiffSource::commit("x").lists_untracked()); assert!( !DiffSource::Range { base: "a".into(), @@ -1284,9 +1412,7 @@ index 1..2 100644 fn commit_files(host: &dyn Host, dir: &Path, spec: &str) -> Vec { let req = DiffRequest { - source: DiffSource::Commit { - rev: rev(dir, spec), - }, + source: DiffSource::commit(rev(dir, spec)), ..Default::default() }; probe_diff(host, dir, &req) diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index b0079703..f584b5ad 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -13,6 +13,7 @@ use std::path::Path; use smallvec::SmallVec; use super::RecordSplitter; +use super::diff::FileStatus; use crate::host::Host; /// Full hex object id. Kept as `String` rather than `[u8; 20]` because sha256 @@ -33,6 +34,10 @@ pub const GRAPH_PAGE: usize = 200; pub const MAX_GRAPH_COMMITS: usize = 5_000; pub const MAX_LANES: Lane = 32; pub const MAX_REFS: usize = 2_000; +/// How many changed files one commit's detail view will hold. A vendored +/// dependency landing in a single commit is tens of thousands of paths, and +/// every one of them would become a row. +pub const MAX_COMMIT_FILES: usize = 1_000; pub const MAX_SUBJECT_BYTES: usize = 512; pub const MAX_BODY_BYTES: usize = 8 * 1024; pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024; @@ -79,6 +84,10 @@ pub struct RefDeco { pub short: String, /// Carried the `HEAD -> ` prefix in `%D`. pub is_head: bool, + /// The full refname this branch tracks, where it tracks one. Only + /// [`for_each_ref`] can fill it in — `%D` says nothing about upstreams — + /// so a decoration parsed out of a `log` record always leaves it `None`. + pub upstream: Option, } #[derive(Clone, PartialEq, Eq, Debug)] @@ -496,6 +505,7 @@ fn parse_deco(text: &str) -> Vec { full: "HEAD".to_string(), short: "HEAD".to_string(), is_head: true, + upstream: None, }); continue; } @@ -524,6 +534,7 @@ fn ref_deco(full: &str, is_head: bool) -> Option { full: full.to_string(), short: short.to_string(), is_head, + upstream: None, }) } @@ -538,11 +549,10 @@ pub fn parse_refs(stdout: &[u8]) -> HashMap> { let full = fields.next().unwrap_or_default().trim(); // `%(refname:short)` is skipped in favour of stripping the prefix here, // so a ref named the same way from `%D` and from here reads identically - // in the UI. `%(upstream)` and `%(objecttype)` are asked for because - // the branch switcher will want them off the same call; neither has a - // home on `RefDeco` yet. + // in the UI. `%(objecttype)` is asked for to keep the format one line + // rather than two; only `%(*objectname)` below acts on it. let _short = fields.next(); - let _upstream = fields.next(); + let upstream = fields.next().unwrap_or_default().trim(); let head = fields.next().unwrap_or_default().trim(); let _kind = fields.next(); let peeled = fields.next().unwrap_or_default().trim(); @@ -553,7 +563,8 @@ pub fn parse_refs(stdout: &[u8]) -> HashMap> { if full.is_empty() || !is_hex_oid(target) { continue; } - if let Some(deco) = ref_deco(full, head == "*") { + if let Some(mut deco) = ref_deco(full, head == "*") { + deco.upstream = (!upstream.is_empty()).then(|| upstream.to_string()); out.entry(target.to_string()).or_default().push(deco); } } @@ -577,6 +588,264 @@ pub fn for_each_ref(host: &dyn Host, root: &Path) -> HashMap> } } +/// The local branch names, one per line, in git's own refname order. +/// +/// `for-each-ref` rather than `branch`: no porcelain warnings, no column +/// layout, and one name per line whatever the user's config says. It is a +/// separate call from [`for_each_ref`] because that one groups by the commit a +/// ref points at, which is the wrong shape for a list of branches — the +/// switcher wants every branch, including the ones sharing a tip. +pub fn local_branches(host: &dyn Host, root: &Path) -> Vec { + let count = format!("--count={MAX_REFS}"); + let args = [ + "for-each-ref", + &count, + "--format=%(refname:short)", + "refs/heads", + ]; + match host.git(root, &args) { + Ok(out) if out.success() => parse_branch_names(&out.stdout), + _ => Vec::new(), + } +} + +pub fn parse_branch_names(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .take(MAX_REFS) + .map(str::to_string) + .collect() +} + +/// One commit's metadata, for a detail view that does not already have it. +/// +/// A commit that is on screen in the graph is already a [`Commit`] in +/// [`CommitPage::commits`], and the caller is expected to hand that over +/// instead of paying for this. What is left is the case the page cannot +/// answer: a commit reached from a parent link, or from anywhere outside the +/// window the graph happens to be holding. +pub fn load_commit(host: &dyn Host, root: &Path, rev: &str) -> Option { + if !is_rev(rev) { + return None; + } + let args = [ + "-c", + "log.showSignature=false", + "show", + "--no-patch", + // Without it `%D` prints short names, and `parse_deco` reads full + // ones — every chip would come back as `RefKind::Other`. + "--decorate=full", + "--no-color", + LOG_PRETTY, + rev, + ]; + let out = host.git(root, &args).ok()?; + if !out.success() { + return None; + } + parse_log(&out.stdout).into_iter().next() +} + +/// One path a commit touched, with the line counts beside it. +/// +/// The counts are `Option` rather than `0` because "git did not say" and +/// "nothing changed" are different answers: a binary file reports neither, and +/// a pure rename reports `0 0`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CommitFile { + /// Repository-relative, and for a rename the *new* name. + pub path: String, + /// Where a rename or a copy came from. + pub orig_path: Option, + pub status: FileStatus, + pub added: Option, + pub removed: Option, + pub binary: bool, +} + +/// The paths one commit touched, against its first parent. +/// +/// Two commands rather than one, because git will take `--numstat` and +/// `--name-status` together and then quietly drop the numstat half — measured +/// on 2.50.1, where the combined `-z` stream comes back as pure name-status. +/// So they are run separately and joined on the path. +/// +/// `log -1 --first-parent`, *not* `diff-tree -m --first-parent`: `diff-tree` +/// does not honour `--first-parent` as a narrowing of a merge. On the same git +/// it emits one diff per parent and concatenates them, so a two-parent merge +/// comes back with a file list twice as long as the merge really is. `log` is +/// also exactly how [`DiffSource::Commit`](super::diff::DiffSource) walks the +/// patch, which is what makes this list and the overlay's cards agree file for +/// file — and it needs no `--root`, because `log` shows a root commit's +/// contents as additions without being asked. +pub fn commit_files(host: &dyn Host, root: &Path, rev: &str) -> Option> { + let numstat = commit_diff(host, root, rev, "--numstat")?; + let name_status = commit_diff(host, root, rev, "--name-status")?; + Some(join_commit_files(&numstat, &name_status)) +} + +fn commit_diff(host: &dyn Host, root: &Path, rev: &str, what: &str) -> Option> { + if !is_rev(rev) { + return None; + } + let args = [ + "-c", + "log.showSignature=false", + // Without it a non-ASCII path comes back wrapped in quotes with its + // bytes spelled as C octal escapes, and nothing here decodes those. + "-c", + "core.quotePath=false", + "log", + "-1", + "--format=", + "--first-parent", + "--no-color", + "-z", + what, + "--find-renames", + rev, + ]; + let out = host.git(root, &args).ok()?; + out.success().then_some(out.stdout) +} + +/// A rev the caller made up is still a rev git will be handed, so anything +/// that could be read as an option is refused before it gets there. +fn is_rev(rev: &str) -> bool { + !rev.is_empty() && !rev.starts_with('-') && !rev.contains(|c: char| c.is_control()) +} + +fn records(stdout: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut on_record = |record: &[u8]| out.push(String::from_utf8_lossy(record).into_owned()); + let mut split = RecordSplitter::new(0); + split.push(stdout, &mut on_record); + split.finish(&mut on_record); + out +} + +/// `-z --numstat`: `\t\t\0` per file — except for a +/// rename or a copy, where the third field is *empty* and the old and the new +/// path follow as two records of their own. A binary file reports `-\t-`. +fn parse_numstat(stdout: &[u8]) -> HashMap, Option, bool)> { + let mut out = HashMap::new(); + let records = records(stdout); + let mut at = 0usize; + while at < records.len() && out.len() < MAX_COMMIT_FILES { + let record = &records[at]; + at += 1; + let mut fields = record.splitn(3, '\t'); + let (Some(added), Some(removed), Some(path)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let binary = added == "-" && removed == "-"; + let counts = (added.parse::().ok(), removed.parse::().ok()); + let path = if path.is_empty() { + let new = records.get(at + 1).cloned(); + at += 2; + match new { + Some(new) => new, + // Truncated mid-rename. Nothing else can be read from here. + None => break, + } + } else { + path.to_string() + }; + out.insert(path, (counts.0, counts.1, binary)); + } + out +} + +/// `-z --name-status`: `\0\0`, and `R\0\0\0` +/// for the two statuses that name two paths. +fn parse_name_status(stdout: &[u8]) -> Vec<(String, Option, FileStatus)> { + let mut out = Vec::new(); + let records = records(stdout); + let mut at = 0usize; + while at < records.len() && out.len() < MAX_COMMIT_FILES { + let code = records[at].trim().to_string(); + at += 1; + let two_paths = matches!(code.as_bytes().first(), Some(b'R' | b'C')); + let taken = 1 + usize::from(two_paths); + let Some(paths) = records.get(at..at + taken) else { + break; + }; + at += taken; + let Some(status) = file_status(&code) else { + continue; + }; + match paths { + [path] => out.push((path.clone(), None, status)), + [old, new] => out.push((new.clone(), Some(old.clone()), status)), + _ => {} + } + } + out +} + +fn file_status(code: &str) -> Option { + match code.as_bytes().first()? { + b'A' => Some(FileStatus::Added), + b'M' => Some(FileStatus::Modified), + b'D' => Some(FileStatus::Deleted), + b'R' => Some(FileStatus::Renamed), + b'C' => Some(FileStatus::Copied), + b'T' => Some(FileStatus::TypeChanged), + // `X` is git's own "unknown"; `B` only appears under + // `--break-rewrites`, which nothing here passes. + b'U' => Some(FileStatus::Unmerged), + _ => None, + } +} + +/// Joins the two streams on the path. +/// +/// `--name-status` is the spine: it carries the letter every row is drawn +/// from, and it is in git's own order. `--numstat` only contributes counts, so +/// losing it costs the numbers and nothing else. Losing the other way round is +/// worse — a path with counts and no letter would vanish — so anything left +/// over is appended rather than dropped. +pub fn join_commit_files(numstat: &[u8], name_status: &[u8]) -> Vec { + let mut counts = parse_numstat(numstat); + let named = parse_name_status(name_status); + let mut out: Vec = Vec::with_capacity(named.len()); + for (path, orig_path, status) in named { + let (added, removed, binary) = counts.remove(&path).unwrap_or((None, None, false)); + out.push(CommitFile { + path, + orig_path, + status, + added, + removed, + binary, + }); + } + let mut leftover: Vec<_> = counts.into_iter().collect(); + // A `HashMap` has no order to preserve, and a file list that reshuffles + // itself between two reads of the same commit would be worse than a + // list that is merely not in git's order. + leftover.sort_by(|a, b| a.0.cmp(&b.0)); + for (path, (added, removed, binary)) in leftover { + if out.len() >= MAX_COMMIT_FILES { + break; + } + out.push(CommitFile { + path, + orig_path: None, + status: FileStatus::Modified, + added, + removed, + binary, + }); + } + out +} + /// Loads the newest `count` commits of `scope` and lays them out. /// /// Paging is a bigger `-n`, never `--skip`. `--skip=M` walks and discards M @@ -1282,6 +1551,195 @@ mod tests { assert_eq!(by_oid[SHA_C][0].short, "origin/dev"); } + #[test] + fn a_branch_keeps_the_upstream_it_tracks() { + let lines = [ + format!( + "{SHA_A}\x1frefs/heads/main\x1fmain\x1frefs/remotes/origin/main\x1f*\x1fcommit\x1f" + ), + // A branch nobody has published tracks nothing, and an empty + // `%(upstream)` has to stay `None` rather than become `Some("")`. + format!("{SHA_B}\x1frefs/heads/local-only\x1flocal-only\x1f\x1f \x1fcommit\x1f"), + format!("{SHA_C}\x1frefs/tags/v9\x1fv9\x1f\x1f \x1fcommit\x1f"), + ]; + let by_oid = parse_refs(lines.join("\n").as_bytes()); + + assert_eq!( + by_oid[SHA_A][0].upstream.as_deref(), + Some("refs/remotes/origin/main") + ); + assert_eq!(by_oid[SHA_B][0].upstream, None); + assert_eq!(by_oid[SHA_C][0].upstream, None); + // `%D` cannot carry an upstream at all, so a decoration parsed out of + // a log record must not claim one. + let logged = parse_log(one(SHA_A, "", "refs/heads/main", "s", "").as_bytes()); + assert_eq!(logged[0].refs[0].upstream, None); + } + + #[test] + fn branch_names_come_back_one_per_line() { + let out = b"main\nfeature/a\n\n spaced \n"; + assert_eq!( + parse_branch_names(out), + ["main", "feature/a", "spaced"], + "blank lines are not branches, and git pads nothing" + ); + assert!(parse_branch_names(b"").is_empty()); + let many: String = (0..MAX_REFS + 50) + .map(|i| format!("b{i}\n")) + .collect::>() + .concat(); + assert_eq!(parse_branch_names(many.as_bytes()).len(), MAX_REFS); + } + + /// Every shape `-z` can produce, from the streams git actually emits — + /// each of these was captured from git 2.50.1 rather than guessed. + #[test] + fn a_commits_file_list_joins_the_two_z_streams() { + // A rename, a path with a space, a path outside ASCII, and a binary. + let numstat = b"1\t0\tbin.dat\x000\t0\t\x00a.txt\x00renamed.txt\x001\t0\twith space.txt\x002\t3\t\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.txt\x00"; + let name_status = b"A\x00bin.dat\x00R100\x00a.txt\x00renamed.txt\x00A\x00with space.txt\x00M\x00\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.txt\x00"; + let files = join_commit_files(numstat, name_status); + + assert_eq!(files.len(), 4, "{files:?}"); + assert_eq!(files[0].path, "bin.dat"); + assert_eq!(files[0].status, FileStatus::Added); + assert_eq!((files[0].added, files[0].removed), (Some(1), Some(0))); + + // The rename: `--numstat` spells it as an empty third field followed + // by two records of its own, and the counts belong to the new name. + assert_eq!(files[1].path, "renamed.txt"); + assert_eq!(files[1].orig_path.as_deref(), Some("a.txt")); + assert_eq!(files[1].status, FileStatus::Renamed); + assert_eq!((files[1].added, files[1].removed), (Some(0), Some(0))); + + assert_eq!( + files[2].path, "with space.txt", + "a space is not a separator" + ); + assert_eq!(files[3].path, "中文名.txt"); + assert_eq!((files[3].added, files[3].removed), (Some(2), Some(3))); + assert!(files.iter().all(|f| !f.binary)); + } + + #[test] + fn a_binary_file_reports_no_counts_rather_than_zero() { + let files = join_commit_files(b"-\t-\tbin2.dat\x00", b"A\x00bin2.dat\x00"); + assert_eq!(files.len(), 1); + assert!(files[0].binary); + assert_eq!( + (files[0].added, files[0].removed), + (None, None), + "`0 0` is a real answer and `-\t-` is not, so they must not read alike" + ); + // A pure rename really does change nothing, and says so. + let renamed = join_commit_files(b"0\t0\t\x00a\x00b\x00", b"R100\x00a\x00b\x00"); + assert!(!renamed[0].binary); + assert_eq!((renamed[0].added, renamed[0].removed), (Some(0), Some(0))); + } + + #[test] + fn one_stream_going_missing_degrades_instead_of_emptying_the_list() { + // No counts: every row still knows what happened to it. + let no_numstat = join_commit_files(b"", b"M\x00a.txt\x00D\x00b.txt\x00"); + assert_eq!(no_numstat.len(), 2); + assert_eq!(no_numstat[1].status, FileStatus::Deleted); + assert!(no_numstat.iter().all(|f| f.added.is_none())); + + // No letters: the paths are the more important half, so they are kept + // and given the one status that claims the least. + let no_names = join_commit_files(b"1\t2\tb.txt\x003\t4\ta.txt\x00", b""); + assert_eq!( + no_names.iter().map(|f| f.path.as_str()).collect::>(), + ["a.txt", "b.txt"], + "with no order to inherit the leftovers are sorted, not shuffled" + ); + assert!(no_names.iter().all(|f| f.status == FileStatus::Modified)); + assert_eq!((no_names[0].added, no_names[0].removed), (Some(3), Some(4))); + + assert!(join_commit_files(b"", b"").is_empty()); + } + + #[test] + fn a_truncated_or_unknown_record_is_dropped_rather_than_shifting_the_parse() { + // A status letter with no path behind it ends the read; anything + // already parsed still stands. + let cut = join_commit_files(b"", b"M\x00a.txt\x00D\x00"); + assert_eq!(cut.len(), 1); + assert_eq!(cut[0].path, "a.txt"); + + // `X` is git's own "something went wrong". Its path is consumed so the + // records after it stay aligned. + let unknown = join_commit_files(b"", b"X\x00weird\x00A\x00good.txt\x00"); + assert_eq!(unknown.len(), 1); + assert_eq!(unknown[0].path, "good.txt"); + + // A rename cut off after its old name leaves nothing to attach to. + assert!(join_commit_files(b"0\t0\t\x00a\x00", b"R100\x00a\x00").is_empty()); + } + + #[test] + fn a_file_list_is_capped_without_losing_its_first_rows() { + let mut numstat = Vec::new(); + let mut name_status = Vec::new(); + for i in 0..MAX_COMMIT_FILES + 20 { + numstat.extend_from_slice(format!("1\t0\tf{i:05}.rs\0").as_bytes()); + name_status.extend_from_slice(format!("A\0f{i:05}.rs\0").as_bytes()); + } + let files = join_commit_files(&numstat, &name_status); + assert_eq!(files.len(), MAX_COMMIT_FILES); + assert_eq!(files[0].path, "f00000.rs"); + } + + #[test] + fn a_rev_that_could_be_read_as_an_option_never_reaches_git() { + assert!(is_rev("HEAD")); + assert!(is_rev(SHA_A)); + assert!(is_rev("v1.0^{commit}")); + assert!(!is_rev("")); + assert!(!is_rev("--upload-pack=touch /tmp/pwned")); + assert!(!is_rev("-n")); + assert!(!is_rev("HEAD\nrm -rf")); + } + + #[test] + fn this_repo_answers_for_one_commit_and_its_files() { + let host = crate::host::local::LocalHost::new(); + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + // A source tarball is a legitimate place to run the tests from. + let Some(page) = load_page(&*host, here, &GraphScope::Head, 2) else { + return; + }; + let Some(head) = page.commits.first() else { + return; + }; + + let shown = load_commit(&*host, here, &head.oid).expect("HEAD is a commit"); + assert_eq!(shown.oid, head.oid); + assert_eq!(shown.summary, head.summary, "the two formats are the same"); + assert_eq!(shown.parents.as_slice(), head.parents.as_slice()); + assert_eq!(shown.author.at, head.author.at); + assert_eq!(load_commit(&*host, here, "-n"), None); + + let files = commit_files(&*host, here, &head.oid).expect("HEAD touched something"); + assert!(!files.is_empty(), "no commit in this repo is empty"); + assert!( + files.iter().all(|f| !f.path.is_empty()), + "an empty path means the join lost a record: {files:?}" + ); + // The whole reason the two commands are run separately. + assert!( + files + .iter() + .any(|f| f.added.is_some() || f.removed.is_some() || f.binary), + "not one row got its counts: {files:?}" + ); + + let branches = local_branches(&*host, here); + assert!(!branches.is_empty(), "this checkout is on a branch"); + assert!(branches.iter().all(|b| !b.starts_with("refs/heads/"))); + } + #[test] fn this_repo_lays_out_one_row_per_commit() { let host = crate::host::local::LocalHost::new(); diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 3dc13f15..ab80d3e3 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -12,8 +12,8 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f use crate::core::config::{Config, DiffViewMode}; use crate::core::git::status::DecoStatus; use crate::terminal::git_diff::{ - self, AUTO_COLLAPSE_LINES, DiffSnapshot, DiffSource, DiffStats, FileDiff, FileStatus, LineKind, - MAX_RENDERED_FILES, Truncation, + self, AUTO_COLLAPSE_LINES, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff, + FileStatus, LineKind, MAX_RENDERED_FILES, Truncation, }; use crate::ui::app::Tty7App; use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; @@ -21,6 +21,7 @@ use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::info_chip; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; +use crate::ui::scm::path::relative_time; use crate::ui::scm::status::{status_color, status_glyph}; pub(crate) enum DiffLoad { @@ -444,6 +445,27 @@ impl Tty7App { &mono, )) }) + // The subject takes the slack the spacer below would otherwise + // have, which is why that one is skipped when a label is present: + // two `flex_1` siblings split the line in half and the subject + // would truncate with empty space beside it. + .when_some(subject.label.as_ref(), |bar, label| { + bar.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .child(SharedString::from(label.subject.clone())), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label_byline(label, now_unix())), + ) + }) .when_some(focused_name(overlay), |bar, name| { bar.child( div().occlude().flex_shrink_0().child( @@ -523,7 +545,7 @@ impl Tty7App { ) }, ) - .child(div().flex_1()) + .when(subject.label.is_none(), |bar| bar.child(div().flex_1())) .child(div().occlude().flex_shrink_0().child({ let sf = cx.global::().window; let selected = usize::from(view_mode(cx) == DiffViewMode::Unified); @@ -1062,7 +1084,7 @@ fn unified_marker(kind: LineKind) -> &'static str { /// `Copied` and `TypeChanged` have no decoration of their own — porcelain v2's /// index folds them the same way — so they take the nearest one rather than /// inventing a `C` and a `T` that appear in the overlay and nowhere else. -fn deco_status(status: FileStatus) -> DecoStatus { +pub(crate) fn deco_status(status: FileStatus) -> DecoStatus { match status { FileStatus::Added => DecoStatus::Added, FileStatus::Modified => DecoStatus::Modified, @@ -1089,6 +1111,10 @@ struct SourceSubject { /// Set only where the branch name alone would be ambiguous. chip: Option<&'static str>, is_rev: bool, + /// What the commit is *about*, where whoever opened it knew. An object id + /// is an address, not a name, and a header with nothing but eight hex + /// digits leaves the reader to remember which commit that was. + label: Option, } fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { @@ -1097,6 +1123,7 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { text: branch.clone(), chip, is_rev: false, + label: None, }; match source { // Worktree and Head are both "the branch, right now"; the header for @@ -1105,21 +1132,45 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { // Staged is the branch too, but a patch that does not match the files // on disk — without the chip it is indistinguishable from the above. DiffSource::Staged => branch_of(Some("STAGED")), - DiffSource::Commit { rev } => SourceSubject { + DiffSource::Commit { rev, label } => SourceSubject { icon: "icons/git-commit.svg", text: short_rev(rev), chip: None, is_rev: true, + // An empty subject is no more use than no label at all, and a + // `Default::default()` that leaked through would render as one. + label: label.clone().filter(|l| !l.subject.is_empty()), }, DiffSource::Range { base, head } => SourceSubject { icon: "icons/git-commit.svg", text: format!("{}…{}", short_rev(base), short_rev(head)), chip: None, is_rev: true, + label: None, }, } } +/// `Ada · 2h`, the byline under a commit's subject. +/// +/// One string rather than two elements: the separator has to disappear along +/// with whichever half is missing, and a `when_some` chain around a middle dot +/// says less than this does. +fn label_byline(label: &CommitLabel, now: i64) -> String { + let when = (label.at > 0).then(|| relative_time(now, label.at)); + match (label.author.trim(), when) { + ("", Some(when)) => when, + (author, Some(when)) => format!("{author} · {when}"), + (author, None) => author.to_string(), + } +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + /// Object ids get cut to eight characters; anything else is already a name a /// person chose, and cutting `origin/main` in half would only hide which it is. fn short_rev(rev: &str) -> String { @@ -1191,15 +1242,19 @@ fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String { /// The de-duplication sets on `Tty7App` are keyed by `(HostId, PathBuf)`, so /// the source rides along inside the path: two sources over one directory are -/// two independent probes and must not cancel one another. `Debug` is what -/// makes the tag unique — it carries the rev of a `Commit` and both ends of a -/// `Range` — and the separator is a byte no path contains. +/// two independent probes and must not cancel one another. +/// +/// `DiffSource::tag` rather than `Debug`, which is what this used to be built +/// from. `Debug` prints a commit's label too, so the same commit opened with a +/// subject in hand and without one would have been two keys and two probes for +/// one patch — the same split `DiffSource`'s own `PartialEq` is written to +/// avoid. The separator is a byte no path contains. fn probe_key( host: crate::ui::host_ops::HostId, cwd: &Path, source: &DiffSource, ) -> (crate::ui::host_ops::HostId, PathBuf) { - let mut tagged = std::ffi::OsString::from(format!("{source:?}\u{1}")); + let mut tagged = std::ffi::OsString::from(format!("{}\u{1}", source.tag())); tagged.push(cwd.as_os_str()); (host, PathBuf::from(tagged)) } @@ -1227,8 +1282,8 @@ mod tests { assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Staged)); assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Head)); assert_ne!( - probe_key(host, cwd, &DiffSource::Commit { rev: "a".into() }), - probe_key(host, cwd, &DiffSource::Commit { rev: "b".into() }), + probe_key(host, cwd, &DiffSource::commit("a")), + probe_key(host, cwd, &DiffSource::commit("b")), "two commits are two probes" ); assert_eq!(worktree, probe_key(host, cwd, &DiffSource::Worktree)); @@ -1236,6 +1291,24 @@ mod tests { worktree, probe_key(host, Path::new("/other"), &DiffSource::Worktree) ); + // …and one commit is one probe however much is known about it. Built + // from `Debug`, as this key once was, the labelled one would have been + // a second in-flight probe for a patch already being read. + assert_eq!( + probe_key(host, cwd, &DiffSource::commit("a")), + probe_key( + host, + cwd, + &DiffSource::Commit { + rev: "a".into(), + label: Some(CommitLabel { + subject: "s".into(), + author: "Ada".into(), + at: 1, + }), + } + ) + ); } #[test] @@ -1307,9 +1380,7 @@ mod tests { ); let commit = source_subject( - &DiffSource::Commit { - rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), - }, + &DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), branch(), ); assert_eq!(commit.icon, COMMIT_ICON); @@ -1327,6 +1398,83 @@ mod tests { assert_eq!(range.text, "main…feature"); } + #[test] + fn a_labelled_commit_says_what_it_was_about() { + let label = CommitLabel { + subject: "fix(scm): stop the panel asking twice".into(), + author: "Ada".into(), + at: 1_786_255_391, + }; + let with = source_subject( + &DiffSource::Commit { + rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + label: Some(label.clone()), + }, + "main".to_string(), + ); + assert_eq!(with.text, "3f2a1b9c", "the sha is still the identifier"); + assert_eq!( + with.label.as_ref().map(|l| l.subject.as_str()), + Some(label.subject.as_str()) + ); + + // Nothing else grows a subject line, least of all a working-tree + // patch, whose "subject" would be a branch name repeated. + assert!( + source_subject(&DiffSource::Worktree, "main".into()) + .label + .is_none() + ); + assert!( + source_subject(&DiffSource::Head, "main".into()) + .label + .is_none() + ); + assert!( + source_subject(&DiffSource::commit("deadbeef"), "main".into()) + .label + .is_none(), + "a commit nobody has read yet has nothing to say" + ); + // A default-constructed label is indistinguishable from none, and must + // not paint an empty row where the subject would go. + let empty = source_subject( + &DiffSource::Commit { + rev: "deadbeef".into(), + label: Some(CommitLabel::default()), + }, + "main".into(), + ); + assert!(empty.label.is_none()); + } + + #[test] + fn the_byline_drops_the_separator_along_with_the_half_it_joined() { + let now = 1_786_255_391 + 7200; + let full = CommitLabel { + subject: "s".into(), + author: "Ada".into(), + at: 1_786_255_391, + }; + assert_eq!(label_byline(&full, now), "Ada · 2h"); + assert_eq!( + label_byline( + &CommitLabel { + author: String::new(), + ..full.clone() + }, + now + ), + "2h", + "a commit with no author is not `· 2h`" + ); + assert_eq!( + label_byline(&CommitLabel { at: 0, ..full }, now), + "Ada", + "and a timestamp that would not parse is not `Ada · 56y`" + ); + } + const BRANCH_ICON: &str = "icons/git-branch.svg"; const COMMIT_ICON: &str = "icons/git-commit.svg"; @@ -1868,8 +2016,14 @@ mod overlay_gpui_tests { DiffSource::Worktree, DiffSource::Staged, DiffSource::Head, + DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), DiffSource::Commit { rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + label: Some(CommitLabel { + subject: "fix(scm): read a commit's own header".into(), + author: "Ada".into(), + at: 1_786_255_391, + }), }, DiffSource::Range { base: "main".into(), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6e1c8200..5317e04e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -857,6 +857,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmResetToCommit => "Reset to Commit", L10nKey::ScmRefresh => "Refresh", L10nKey::ScmBackToChanges => "Back", + L10nKey::ScmCommitParents => "Parents", + L10nKey::ScmShowMore => "Show more", + L10nKey::ScmShowLess => "Show less", + L10nKey::ScmCommitNotFound => "This commit is not in this repository.", L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", L10nKey::ScmOpenChanges => "Open Changes", L10nKey::ScmDiscardAllConfirm => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4a782126..7b1dbf32 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -907,6 +907,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "このコミットにリセット", L10nKey::ScmRefresh => "更新", L10nKey::ScmBackToChanges => "戻る", + L10nKey::ScmCommitParents => "親コミット", + L10nKey::ScmShowMore => "続きを表示", + L10nKey::ScmShowLess => "折りたたむ", + L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。", L10nKey::ScmTooManyChanges => { "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 81cf061a..95df2ccd 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -683,6 +683,13 @@ pub enum L10nKey { ScmResetToCommit, ScmRefresh, ScmBackToChanges, + /// Header over the parent links in the commit detail view. + ScmCommitParents, + /// The fold toggle under a long commit body. + ScmShowMore, + ScmShowLess, + /// The detail view asked git for a commit and git did not have one. + ScmCommitNotFound, /// Shown when the working tree has more changes than the status parser /// keeps. The list is still useful; the count at the top would otherwise /// be a lie. @@ -1135,12 +1142,10 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmGraphAllBranches, L10nKey::ScmGraphEmpty, L10nKey::ScmCommitDetailTitle, - L10nKey::ScmCopyCommitSha, L10nKey::ScmCherryPick, L10nKey::ScmRevertCommit, L10nKey::ScmResetToCommit, L10nKey::ScmRefresh, - L10nKey::ScmBackToChanges, L10nKey::ScmTooManyChanges, L10nKey::ScmOpenChanges, L10nKey::ScmDiscardAllConfirm, @@ -1152,7 +1157,6 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmOpBisect, L10nKey::ScmOpAm, L10nKey::ScmSwitchRepository, - L10nKey::ScmFilesChanged, L10nKey::DiffViewSplit, L10nKey::DiffViewUnified, ]; diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e23182a7..5d662d8d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -830,6 +830,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmResetToCommit => "重置到该提交", L10nKey::ScmRefresh => "刷新", L10nKey::ScmBackToChanges => "返回", + L10nKey::ScmCommitParents => "父提交", + L10nKey::ScmShowMore => "展开", + L10nKey::ScmShowLess => "收起", + L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", L10nKey::ScmOpenChanges => "查看改动", L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。", diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index ea6c972a..323f8939 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -8,20 +8,891 @@ //! //! The patch itself still goes to the full-screen overlay. 260px is not a //! place to read a diff. +//! +//! This is also where the panel pays back what the graph gave up. A history +//! row has about 26 characters beside its lanes and this repository's subjects +//! run to a median of 64, so the graph shows shape and this shows text: the +//! whole subject, the body, every ref, the parents, and the files. -use gpui::{AnyElement, Context}; +use std::sync::Arc; -use crate::ui::app::Tty7App; -use crate::ui::scm::state::CommitDetailView; +use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use tty7_core::core::git::diff::CommitLabel; +use tty7_core::core::git::log::{Commit, CommitFile, RefKind}; +use tty7_core::core::git::status::DecoStatus; + +use crate::terminal::git_diff::DiffSource; +use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::i18n::{L10nKey, t, t_plural}; +use crate::ui::right_panel::{git_badge, info_chip}; +use crate::ui::scm::path::{relative_time, split_display_path}; +use crate::ui::scm::state::{CommitDetailView, RepoKey}; +use crate::ui::scm::status::{status_color, status_glyph}; + +/// A file row, the same height as the working tree's, and inset the same way. +/// The two lists sit in one column and have to read as one grid. +const ROW_H: f32 = 24.; +const ROW_INSET: f32 = 4.; + +/// How much of the body is shown before it folds. Four lines is a paragraph; +/// past that it is a changelog, and the file list is what the reader came for. +const BODY_LINES: usize = 4; + +/// And how much of the subject, which wraps rather than folding. Three lines +/// of 12px in 260px is around 90 characters — longer than every subject in +/// this repository but a handful, and a cap for the ones that are a paragraph. +const SUBJECT_LINES: usize = 3; impl Tty7App { + /// Show one commit, replacing the working tree in the panel body. + /// + /// `seed` is the commit the caller already has. The graph's page carries + /// every field this view renders, so a click on a row hands its own + /// [`Commit`] over and no `git show` is run at all; a parent link, or + /// anything else reaching a commit outside that window, passes `None` and + /// pays for the read. + pub(crate) fn open_commit_detail( + &mut self, + repo: RepoKey, + oid: String, + seed: Option, + cx: &mut Context, + ) { + self.scm.detail = Some(CommitDetailView::new(repo, oid, seed)); + cx.notify(); + } + + pub(crate) fn close_commit_detail(&mut self, cx: &mut Context) { + if self.scm.detail.take().is_some() { + cx.notify(); + } + } + /// The commit detail body, shown in place of the file groups. pub(crate) fn render_commit_detail( &mut self, - _detail: &CommitDetailView, - _window: &mut gpui::Window, - _cx: &mut Context, + detail: &CommitDetailView, + _window: &mut Window, + cx: &mut Context, ) -> Option { - None + // The detail names its own repository, and the panel may since have + // followed the active pane somewhere else. A commit from a repository + // nobody is looking at any more is not a second-level view of + // anything, so it goes rather than sitting on top of the wrong tree. + if self.scm.active_repo() != Some(&detail.repo) { + self.scm.detail = None; + return None; + } + self.load_commit_detail(detail, cx); + + let mono = cx.theme().mono_font_family.clone(); + let muted = cx.theme().muted_foreground; + // Each section insets itself rather than sharing one on the column: + // `panel_subtitle` applies `CONTENT_INSET` of its own, and an outer + // inset would push it eight pixels right of the rows beneath it. + let mut body = v_flex() + .py(px(2.)) + .child(self.detail_header_row(detail, &mono, cx)); + + match detail.commit.as_deref() { + Some(commit) => { + body = body + .child(self.detail_message(detail, commit, cx)) + .children(self.detail_refs(commit, &mono, cx)) + .children(self.detail_parents(detail, commit, &mono, cx)) + .child(self.detail_files(detail, commit, &mono, cx)); + } + // Nothing came back. `loaded` is what tells "still reading" apart + // from "git has no such commit here" — without it a bad oid would + // read as a spinner that never stops. + None => { + body = body.child( + div() + .px(px(CONTENT_INSET)) + .py(px(4.)) + .text_size(px(12.)) + .text_color(muted) + .child(if detail.loaded { + t(L10nKey::ScmCommitNotFound) + } else { + t(L10nKey::PanelLoading) + }), + ); + } + } + Some(body.into_any_element()) + } + + /// Read the commit and its file list, once. + /// + /// Runs from `render`, so it has to be idempotent in the strongest sense: + /// the panel is redrawn on every status change and a second dispatch would + /// mean a `git show` per frame. `loading` covers the window while a read + /// is out and `loaded` covers every frame after it lands, including the + /// ones where it landed with nothing. + fn load_commit_detail(&mut self, detail: &CommitDetailView, cx: &mut Context) { + if detail.loading || detail.loaded { + return; + } + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, detail.repo.host) else { + return; + }; + if let Some(open) = self.scm.detail.as_mut() { + open.loading = true; + } + let root = detail.repo.root.clone(); + let oid = detail.oid.clone(); + // A seeded view already has its metadata and only wants the files, so + // the `show` is skipped rather than run for an answer we hold. + let seeded = detail.commit.is_some(); + let key = (detail.repo.clone(), detail.oid.clone()); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| { + use tty7_core::core::git::log; + let commit = (!seeded) + .then(|| log::load_commit(h, &root, &oid)) + .flatten(); + (commit, log::commit_files(h, &root, &oid)) + }, + move |this, (commit, files), cx| { + // The user may have gone back, or moved on to another commit, + // while the read was out. Landing it anywhere but on the view + // that asked would show one commit's files under another's + // message. + let Some(open) = this + .scm + .detail + .as_mut() + .filter(|d| (d.repo.clone(), d.oid.clone()) == key) + else { + return; + }; + open.loading = false; + open.loaded = true; + if let Some(commit) = commit { + open.commit = Some(Arc::new(commit)); + } + open.files = Some(Arc::new(files.unwrap_or_default())); + cx.notify(); + }, + ); + } + + /// The way back, and the object id. + /// + /// The back affordance belongs in `panel_title`'s trailing slot, where the + /// diff overlay puts its own. It is here instead because the title is + /// rendered by the panel and this function only produces the body — see + /// the note in `render_panel_scm`. Being the first row of the body it + /// scrolls with the content, which is the one thing lost by the move. + fn detail_header_row( + &self, + detail: &CommitDetailView, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let oid = detail.oid.clone(); + h_flex() + .items_center() + .gap(px(4.)) + .h(px(ROW_H)) + .px(px(CONTENT_INSET - ROW_INSET)) + .child( + h_flex() + .id("scm-detail-back") + .items_center() + .gap(px(2.)) + .px(px(4.)) + .py(px(1.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .on_click(cx.listener(|this, _, _window, cx| this.close_commit_detail(cx))) + .child( + Icon::new(IconName::ChevronLeft) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child(div().text_xs().child(t(L10nKey::ScmBackToChanges))), + ) + .child(div().flex_1().min_w_0()) + .child( + div() + .id("scm-detail-sha") + .flex_none() + .px(px(4.)) + .py(px(1.)) + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .text_size(px(13.)) + .font_family(mono.clone()) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::ScmCopyCommitSha)) + .build(window, cx) + }) + .on_click(move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(oid.clone())); + }) + .child(short_oid(&detail.oid).to_string()), + ) + .into_any_element() + } + + /// Subject, byline, body. + fn detail_message( + &self, + detail: &CommitDetailView, + commit: &Commit, + cx: &mut Context, + ) -> AnyElement { + let body = commit.body.trim(); + let lines = body.lines().count(); + let folded = !detail.body_expanded && lines > BODY_LINES; + v_flex() + .px(px(CONTENT_INSET)) + .pb(px(4.)) + .gap(px(3.)) + .child( + // Wrapping, not truncating: this view exists because the + // graph row could only show the first 26 characters. + div() + .text_size(px(12.)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_clamp(SUBJECT_LINES) + .child(SharedString::from(commit.summary.clone())), + ) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(byline(commit, now_unix())), + ) + .when(!body.is_empty(), |this| { + this.child( + div() + .pt(px(2.)) + .text_size(px(11.5)) + .text_color(cx.theme().muted_foreground) + .when(folded, |d| d.line_clamp(BODY_LINES)) + .child(SharedString::from(body.to_string())), + ) + .when(lines > BODY_LINES, |this| { + this.child( + div() + .id("scm-detail-body-fold") + .w_full() + .py(px(1.)) + .cursor_pointer() + .text_size(px(11.)) + .text_color(cx.theme().info) + .on_click(cx.listener(|this, _, _window, cx| { + if let Some(open) = this.scm.detail.as_mut() { + open.body_expanded = !open.body_expanded; + cx.notify(); + } + })) + .child(t(if folded { + L10nKey::ScmShowMore + } else { + L10nKey::ScmShowLess + })), + ) + }) + }) + .into_any_element() + } + + /// Every ref pointing here, wrapped over as many lines as it takes. + /// + /// The graph row shows one chip and a `+N`; there is no reason to hide any + /// of them once there is a whole column to put them in. + fn detail_refs( + &self, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> Option { + if commit.refs.is_empty() { + return None; + } + let theme = cx.theme(); + let (accent, warning, fg, muted) = ( + theme.accent, + theme.warning, + theme.foreground, + theme.muted_foreground, + ); + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .px(px(CONTENT_INSET)) + .pb(px(6.)); + for deco in &commit.refs { + // The same three colours the graph's chips use: a tag is yellow + // because a tag is yellow everywhere in git, HEAD is emphasised, + // and everything else is quiet. + let (bg, color) = match deco.kind { + RefKind::Tag => (warning.opacity(0.16), warning), + _ if deco.is_head => (accent.opacity(0.28), fg), + _ => (accent, muted), + }; + row = row.child(info_chip(&deco.short, bg, color, mono)); + } + Some(row.into_any_element()) + } + + /// The parents, as links. Following one is the only way to walk history + /// backwards from a commit the graph's window does not reach. + fn detail_parents( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> Option { + if commit.parents.is_empty() { + return None; + } + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap(px(6.)) + .px(px(CONTENT_INSET)) + .pb(px(4.)) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::ScmCommitParents)), + ); + for parent in &commit.parents { + let repo = detail.repo.clone(); + let oid = parent.clone(); + row = row.child( + div() + .id(SharedString::from(format!("scm-detail-parent-{parent}"))) + .px(px(3.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .text_size(px(11.)) + .font_family(mono.clone()) + .text_color(cx.theme().info) + .on_click(cx.listener(move |this, _, _window, cx| { + // No seed: a parent is by definition one step past + // whatever the caller had in hand. + this.open_commit_detail(repo.clone(), oid.clone(), None, cx); + })) + .child(short_oid(parent).to_string()), + ); + } + Some(row.into_any_element()) + } + + fn detail_files( + &self, + detail: &CommitDetailView, + commit: &Commit, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let files = detail.files.clone().unwrap_or_default(); + let mut list = v_flex().child(self.panel_subtitle( + &t_plural(L10nKey::ScmFilesChanged, files.len(), &[]), + true, + None, + cx, + )); + if detail.files.is_none() { + return list + .child(self.detail_note(t(L10nKey::PanelLoading).to_string(), cx)) + .into_any_element(); + } + // The label rides along on the source so the overlay's header can say + // which commit it is showing, and it is deliberately not part of that + // source's identity — the same commit opened from here and from + // anywhere else has to stay one overlay. + let source = DiffSource::Commit { + rev: detail.oid.clone(), + label: Some(CommitLabel { + subject: commit.summary.clone(), + author: commit.author.name.clone(), + at: commit.author.at.unix, + }), + }; + // The rows sit in the working tree's own column: laid out one + // `ROW_INSET` short of `CONTENT_INSET` and padding themselves back + // out, so a hovered row's background is wider than its text. + let mut rows = v_flex().px(px(CONTENT_INSET - ROW_INSET)); + for file in files.iter() { + rows = rows.child(self.detail_file_row(detail, &source, file, mono, cx)); + } + list.child(rows).into_any_element() + } + + /// The working tree's file row, minus the hover buttons. + /// + /// A copy of `scm_file_row`, which is the wrong way round and known to be: + /// the two have to stay pixel-identical and nothing here enforces that. + /// They differ only in what they are built from — a `StatusEntry` against + /// a [`CommitFile`] — and in the buttons, so the shared version is a + /// function over `(letter, deco, path)` plus an optional trailing element. + fn detail_file_row( + &self, + detail: &CommitDetailView, + source: &DiffSource, + file: &CommitFile, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let deco = crate::ui::diff_overlay::deco_status(file.status); + let (name, dir) = split_display_path(&file.path); + let selected = self.diff_overlay_focus(detail.repo.host, &detail.repo.root) + == Some(file.path.as_str()); + + h_flex() + .id(SharedString::from(format!("scm-detail-file-{}", file.path))) + .items_center() + .gap(px(8.)) + .h(px(ROW_H)) + .px(px(ROW_INSET)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .when(selected, |s| s.bg(gpui::rgb(sf.selected))) + .on_click({ + let repo = detail.repo.clone(); + let source = source.clone(); + let path = file.path.clone(); + cx.listener(move |this, _, window, cx| { + // 260px cannot render a patch, so the file level is the + // full-screen overlay's job — the same one the working + // tree's rows open, pointed at a commit instead. + this.open_diff_overlay( + repo.host, + repo.root.clone(), + source.clone(), + Some(path.clone()), + window, + cx, + ); + }) + }) + .child(git_badge(status_glyph(deco), status_color(deco, cx), mono)) + .child( + div() + .flex_none() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if deco == DecoStatus::Deleted { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .when(deco == DecoStatus::Deleted, |s| s.line_through()) + .child(name.to_string()), + ) + .when(!dir.is_empty(), |this| { + this.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(dir.to_string()), + ) + }) + .into_any_element() + } + + fn detail_note(&self, text: String, cx: &mut Context) -> AnyElement { + div() + .px(px(CONTENT_INSET)) + .py(px(3.)) + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(text) + .into_any_element() + } +} + +/// `Ada Lovelace · 2h`. Author, not committer: a rebase rewrites the second +/// one, and "who wrote this" is the question a reader is asking. +pub(crate) fn byline(commit: &Commit, now: i64) -> String { + let when = (commit.author.at.unix > 0).then(|| relative_time(now, commit.author.at.unix)); + match (commit.author.name.trim(), when) { + ("", Some(when)) => when, + (name, Some(when)) => format!("{name} · {when}"), + (name, None) => name.to_string(), + } +} + +/// Seven, which is what git itself prints and what the graph's rows use. +pub(crate) fn short_oid(oid: &str) -> &str { + &oid[..oid.len().min(7)] +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tty7_core::core::git::log::{OffsetTs, Signature}; + + fn commit(name: &str, at: i64) -> Commit { + Commit { + oid: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(), + parents: Default::default(), + author: Signature { + name: name.into(), + email: "ada@example.com".into(), + at: OffsetTs { + unix: at, + offset_minutes: 0, + }, + }, + committer: Signature { + name: "Grace".into(), + email: "grace@example.com".into(), + at: OffsetTs { + unix: at, + offset_minutes: 0, + }, + }, + summary: "s".into(), + body: String::new(), + refs: Vec::new(), + } + } + + #[test] + fn the_byline_drops_the_separator_along_with_the_half_it_joined() { + let now = 1_786_255_391 + 7200; + assert_eq!(byline(&commit("Ada", 1_786_255_391), now), "Ada · 2h"); + assert_eq!( + byline(&commit("", 1_786_255_391), now), + "2h", + "an unattributed commit is not `· 2h`" + ); + assert_eq!( + byline(&commit("Ada", 0), now), + "Ada", + "and a date that would not parse is not `Ada · 56y`" + ); + } + + #[test] + fn a_short_oid_is_the_seven_characters_git_itself_prints() { + assert_eq!( + short_oid("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"), + "3f2a1b9" + ); + assert_eq!(short_oid("abc"), "abc", "a truncated oid is not padded"); + assert_eq!(short_oid(""), ""); + } +} + +/// The detail view against a real repository, drawn in a real window. +/// +/// Construction alone would prove very little: everything that can go wrong +/// here — a missing global, a theme token, a slice through the middle of a +/// character — goes wrong during layout and paint, so these arm the render +/// probe and insist something was actually drawn. +#[cfg(all(test, unix))] +mod detail_gpui_tests { + use super::*; + use crate::daemon::protocol::DaemonMsg; + use crate::ui::app::{render_probe, test_window}; + use crate::ui::host_ops::HostId; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use std::path::{Path, PathBuf}; + use tty7_core::core::config::RightPanelTab; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-detail-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(&dir).unwrap() + } + + fn git(root: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?} failed"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + /// Two commits: a root, then one that renames a file, adds a path with a + /// space in it and writes a body long enough to fold. + fn two_commit_repo(name: &str) -> PathBuf { + let root = scratch(name); + git(&root, &["init", "--quiet"]); + git(&root, &["config", "user.email", "ada@example.com"]); + git(&root, &["config", "user.name", "Ada"]); + std::fs::write(root.join("a.txt"), "one\n").unwrap(); + git(&root, &["add", "a.txt"]); + git(&root, &["commit", "-qm", "root commit"]); + std::fs::rename(root.join("a.txt"), root.join("renamed.txt")).unwrap(); + std::fs::write(root.join("with space.txt"), "two\n").unwrap(); + std::fs::write(root.join("中文名.txt"), "three\n").unwrap(); + git(&root, &["add", "-A"]); + git( + &root, + &[ + "commit", + "-qm", + "feat(detail): a subject long enough that the graph row could never have shown it", + "-m", + "one\ntwo\nthree\nfour\nfive\nsix", + ], + ); + root + } + + fn panel_on( + cx: &mut TestAppContext, + root: &Path, + ) -> ( + Entity, + VisualTestContext, + std::os::unix::net::UnixStream, + ) { + let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx); + DaemonMsg::Cwd(root.to_path_buf()) + .encode(&mut pane) + .expect("the pane's socket takes the cwd"); + app.update_in(&mut vcx, |app, _, cx| { + app.right_panel_visible = true; + app.right_panel_tab = RightPanelTab::Scm; + cx.notify(); + }); + let want = root.to_path_buf(); + settle(&app, &mut vcx, move |app, _| { + app.scm.repo.as_ref().is_some_and(|r| r.root == want) + }); + (app, vcx, pane) + } + + /// Pump frames until the panel has done what it was asked. The panel only + /// starts a read from `render`, so nothing here can be awaited directly. + fn settle( + app: &Entity, + vcx: &mut VisualTestContext, + done: impl Fn(&Tty7App, &gpui::App) -> bool, + ) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + app.update_in(vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + if app.update_in(vcx, |app, _, cx| done(app, cx)) { + return; + } + assert!( + std::time::Instant::now() < deadline, + "the panel never settled" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + fn paths(app: &Entity, vcx: &mut VisualTestContext) -> Vec { + app.update_in(vcx, |app, _, _| { + app.scm + .detail + .as_ref() + .and_then(|d| d.files.clone()) + .map(|files| files.iter().map(|f| f.path.clone()).collect()) + .unwrap_or_default() + }) + } + + #[gpui::test] + fn a_commit_detail_reads_its_own_files_and_draws_them(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("draws"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), None, cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + // Nothing was seeded, so the metadata came from `git show`. + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().expect("the detail is open"); + let commit = detail.commit.as_ref().expect("git resolved the commit"); + assert_eq!(commit.oid, head); + assert!(commit.summary.starts_with("feat(detail):")); + assert_eq!(commit.parents.len(), 1); + assert_eq!(commit.body.lines().count(), 6, "long enough to fold"); + }); + let mut listed = paths(&app, &mut vcx); + listed.sort(); + assert_eq!( + listed, + ["renamed.txt", "with space.txt", "中文名.txt"], + "the two -z streams joined into one list" + ); + + // A real frame, so layout and paint run over every row above. + render_probe::arm(10_000); + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + assert!( + render_probe::draws() > 0, + "nothing was drawn, so nothing was proved" + ); + + // Expanding the body is another branch of the same element. + app.update_in(&mut vcx, |app, _, cx| { + app.scm.detail.as_mut().unwrap().body_expanded = true; + cx.notify(); + }); + render_probe::arm(10_000); + app.update_in(&mut vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + assert!(render_probe::draws() > 0); + + // The read runs from `render`, which is the shape that has spun this + // panel before: a dispatch that did not record itself would ask git + // for the same commit again on the frame its own answer caused. + assert_eq!(draws_while_idle(&mut vcx), 0); + + let _ = std::fs::remove_dir_all(&root); + } + + /// Copied from `panel.rs`'s own idle tests: arm the probe, let every timer + /// the panel owns fire, and count the frames nobody asked for. + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { + render_probe::arm(200); + vcx.background_executor.run_until_parked(); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(3)); + vcx.background_executor.run_until_parked(); + render_probe::arm(200); + vcx.executor() + .advance_clock(std::time::Duration::from_secs(9)); + vcx.background_executor.run_until_parked(); + render_probe::draws() + } + + #[gpui::test] + fn following_a_parent_swaps_the_commit_and_going_back_clears_it(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("parent"); + let head = git(&root, &["rev-parse", "HEAD"]); + let parent = git(&root, &["rev-parse", "HEAD^"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), None, cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + // What the parent link does: the same call with the other oid, and + // nothing carried over from the commit that was on screen. + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), parent.clone(), None, cx) + }); + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().unwrap(); + assert_eq!(detail.oid, parent); + assert!(detail.commit.is_none(), "the old commit did not linger"); + assert!(!detail.loaded); + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + assert_eq!( + paths(&app, &mut vcx), + ["a.txt"], + "a root commit's files are what it added, with no --root needed" + ); + + app.update_in(&mut vcx, |app, _, cx| app.close_commit_detail(cx)); + assert!(app.update_in(&mut vcx, |app, _, _| app.scm.detail.is_none())); + let _ = std::fs::remove_dir_all(&root); + } + + #[gpui::test] + fn a_seeded_detail_only_asks_for_the_files(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("seeded"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + let repo = app.update_in(&mut vcx, |app, _, _| app.scm.repo.clone().unwrap()); + + // What the graph hands over: a row it already holds. The subject is + // deliberately not the real one, so a `git show` behind our back would + // overwrite it and show up here. + let mut seed = tty7_core::core::git::log::load_commit( + &*tty7_core::host::local::LocalHost::new(), + &root, + &head, + ) + .expect("the scratch repo answers"); + seed.summary = "what the graph already knew".into(); + app.update_in(&mut vcx, |app, _, cx| { + app.open_commit_detail(repo.clone(), head.clone(), Some(seed), cx) + }); + settle(&app, &mut vcx, |app, _| { + app.scm.detail.as_ref().is_some_and(|d| d.loaded) + }); + + app.update_in(&mut vcx, |app, _, _| { + let detail = app.scm.detail.as_ref().unwrap(); + assert_eq!( + detail.commit.as_ref().unwrap().summary, + "what the graph already knew", + "the seed was kept, so no second read of the same commit happened" + ); + assert_eq!(detail.files.as_ref().unwrap().len(), 3); + }); + let _ = std::fs::remove_dir_all(&root); + } + + /// A commit from a repository the panel has since walked away from is not + /// a second-level view of anything. + #[gpui::test] + fn a_detail_from_another_repository_gives_the_body_back(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let root = two_commit_repo("elsewhere"); + let head = git(&root, &["rev-parse", "HEAD"]); + let (app, mut vcx, _pane) = panel_on(cx, &root); + + app.update_in(&mut vcx, |app, window, cx| { + let stranger = RepoKey { + host: HostId::LOCAL, + root: PathBuf::from("/no/such/tty7/repo"), + }; + app.open_commit_detail(stranger.clone(), head.clone(), None, cx); + let detail = app.scm.detail.clone().unwrap(); + assert!(app.render_commit_detail(&detail, window, cx).is_none()); + assert!(app.scm.detail.is_none(), "and it does not come back"); + }); + let _ = std::fs::remove_dir_all(&root); } } diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index ff6da7c7..04d7ba65 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,11 +4,10 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// What is left unused is what the graph and the commit detail view will call: -// `relative_time` has no row to date yet, and `status_rank` is the file tree's -// to use. Both allows come off with the step that wires them up. +// What is left unused is what the graph will call, plus `status_rank`, which +// is the file tree's to use. Both allows come off with the step that wires +// them up. pub(crate) mod actions; -#[allow(dead_code)] pub(crate) mod detail; #[allow(dead_code)] pub(crate) mod graph; diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 96880939..121fe514 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -12,9 +12,11 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::Arc; use gpui::Entity; use gpui_component::input::InputState; +use tty7_core::core::git::log::{Commit, CommitFile}; use tty7_core::core::git::status::HeadState; use crate::ui::host_ops::HostId; @@ -174,11 +176,49 @@ pub(crate) struct GraphState { /// touched. A file-level diff is not shown here — that opens the full-screen /// overlay, because 260px cannot render a diff and pretending otherwise would /// mean inventing a third kind of container. +/// +/// The two loaded halves are behind `Arc` because the panel clones this whole +/// struct once per frame — `render_panel_scm` cannot hand `render_commit_ +/// detail` a borrow of `self.scm` and a `&mut self` at once — and a commit +/// that touched a thousand files would otherwise deep-copy a thousand paths +/// every time anything on the panel redrew. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct CommitDetailView { pub(crate) repo: RepoKey, pub(crate) oid: String, + /// A read is out. Set before it is dispatched, so the render that runs in + /// between does not ask for a second one. pub(crate) loading: bool, + /// Whether a read has ever come back. With `loading` it is what stops the + /// view asking again forever after a commit git could not resolve: the + /// pair says "nothing is in flight and nothing is coming". + pub(crate) loaded: bool, + /// `None` until a read lands, and still `None` afterwards for a commit + /// that is not in this repository. + pub(crate) commit: Option>, + pub(crate) files: Option>>, + /// A long body starts folded — a merge from a bot can run to fifty lines, + /// and the file list is what the reader came for. + pub(crate) body_expanded: bool, +} + +impl CommitDetailView { + /// A commit the panel is about to show. `seed` is the row the graph + /// already has in hand, where the caller came from the graph: the page + /// carries every field a detail view needs, so handing it over is what + /// keeps the common path from running `git show` for a commit that is + /// literally on screen. + pub(crate) fn new(repo: RepoKey, oid: String, seed: Option) -> CommitDetailView { + CommitDetailView { + repo, + oid, + loading: false, + loaded: false, + commit: seed.map(Arc::new), + files: None, + body_expanded: false, + } + } } #[cfg(test)] From 5418d66903c1b075cb12a430a0bf664346df9732 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:44:16 +0800 Subject: [PATCH 27/36] feat(scm): draw the commit graph in the panel's history section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history section now renders a real `CommitPage`: lanes, nodes, merge rings, refs and ages, over rows that behave like every other row in the panel. It replaces the three-lane figure from the G7·0 spike, whose shape it keeps unchanged — one canvas over the whole list, `paint_quad` for everything, lane centres snapped to device pixels before the quad is built. What the section is for decided most of the rest. 260px leaves about 26 characters beside the gutter, and this repository's subjects run to a median of 64, so reading a message here was never going to work: what a reader gets is where the branches are, where they merged, which refs sit where, and how recently anything moved. Two things buy back what can be bought: - The conventional-commit prefix comes off into a chip. `feat(terminal): ` is 12.7 characters on average, and the type is exactly the part that reads better as three coloured characters than as prose. The split is strict, so `Merge pull request`, `fix:it` and a bare URL all keep their whole line. - The lane gutter folds to a single column on request, worth another six. Lane colours are derived, not tabled. `Theme::lanes()` seeds from the palette in the order blue, yellow, magenta, green, cyan, red — no two neighbours share a hue family, red and green are never adjacent, and red is last because a panel three or four lanes wide never reaches it — then walks each one to `ACCENT_FLOOR` on the window, the sidebar and a popover. Across the nine builtins the worst contrast is 3.00:1 (untreated, `catppuccin_latte` sits at 2.31 and `rose_pine_dawn` at 2.05) and the worst adjacent pair is ΔE 13.8, against a JND of about 2.3. A hard-coded palette would have been the one colour in this file that ignores the theme, and the contrast tests cannot see a literal. Some notes on the drawing: - Segments are deduplicated by column before anything is painted, which is what makes the overflow bundle work: five lanes folded into the last column produce one line, not five stacked at five alphas. `project` is a pure projection and never feeds back into the layout, so dragging the panel narrower re-columns for free and no branch changes colour. - Cross-lane turns are right angles, and at a 12px pitch they read unambiguously — the same call tig, lazygit and `git log --graph` make. The horizontal runs half a line width past both centres, which is exactly what closes the corners the vertical stubs leave open. - No `paint_layer` per line. Zed's graph does that; each one is a full-drawable render pass. `BoundsTree` already orders overlapping primitives, and edges arrive sorted by `paint_rank`, so the node's own line lands last. - Nodes are rounded quads rather than paths: the quad shader rounds with an exact SDF and analytic anti-aliasing, where `PathBuilder` fills every vertex's `st` with `(0, 1)` and gets 4x MSAA alone. - Paging grows `requested` and re-runs the query. The layout is deterministic, so a longer run reproduces the same prefix row for row and nothing on screen moves; `--skip` is O(skip) and slides under you when a ref moves. It is a row, not a scroll trigger — a remote `git log` is an RPC, and scroll-to-load turns one flick into a burst of them. - Filtering hides the gutter. Lanes drawn across a subset of history would connect commits that are not adjacent, so a search result is a flat list, which is what it actually is. Seventeen tests. Four in `presets` run with the existing contrast batch and assert the floor on all three surfaces, adjacent ΔE, determinism and the seed order. The rest cover projection, snapped lane centres, the width clamp, the prefix split (including a Chinese subject, which is where byte indexing goes wrong), band deduplication, the filter and the scope label. One runs a real repository through a real pane and asserts the settled section draws zero frames while idle — a canvas that repaints every frame reads as correct code. --- src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 16 + src/ui/i18n/zh.rs | 8 + src/ui/presets.rs | 185 ++++- src/ui/scm/graph.rs | 1869 +++++++++++++++++++++++++++++++++++++------ src/ui/scm/mod.rs | 9 +- src/ui/scm/state.rs | 28 +- src/ui/theme.rs | 4 + 9 files changed, 1891 insertions(+), 244 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6e1c8200..ddd2647b 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -850,6 +850,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmGraphFilterPlaceholder => "Filter commits…", L10nKey::ScmGraphAllBranches => "All Branches", L10nKey::ScmGraphEmpty => "No commits yet", + L10nKey::ScmGraphCurrentBranch => "Current Branch", + L10nKey::ScmGraphFoldLanes => "Hide Lanes", + L10nKey::ScmGraphShowLanes => "Show Lanes", + 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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4a782126..bd9973af 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -900,6 +900,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGraphFilterPlaceholder => "コミットを絞り込む…", L10nKey::ScmGraphAllBranches => "すべてのブランチ", L10nKey::ScmGraphEmpty => "まだコミットがありません", + L10nKey::ScmGraphCurrentBranch => "現在のブランチ", + L10nKey::ScmGraphFoldLanes => "レーンを隠す", + L10nKey::ScmGraphShowLanes => "レーンを表示", + L10nKey::ScmCheckoutCommit => "このコミットをチェックアウト", + L10nKey::ScmCreateBranchHere => "ここにブランチを作成…", + L10nKey::ScmResetSoft => "リセット(ソフト)", + L10nKey::ScmResetMixed => "リセット(ミックス)", + L10nKey::ScmResetHard => "リセット(ハード)", L10nKey::ScmCommitDetailTitle => "コミット", L10nKey::ScmCopyCommitSha => "コミット SHA をコピー", L10nKey::ScmCherryPick => "チェリーピック", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 81cf061a..650720d8 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -676,6 +676,14 @@ pub enum L10nKey { ScmGraphFilterPlaceholder, ScmGraphAllBranches, ScmGraphEmpty, + ScmGraphCurrentBranch, + ScmGraphFoldLanes, + ScmGraphShowLanes, + ScmCheckoutCommit, + ScmCreateBranchHere, + ScmResetSoft, + ScmResetMixed, + ScmResetHard, ScmCommitDetailTitle, ScmCopyCommitSha, ScmCherryPick, @@ -1134,6 +1142,14 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmGraphFilterPlaceholder, L10nKey::ScmGraphAllBranches, L10nKey::ScmGraphEmpty, + L10nKey::ScmGraphCurrentBranch, + L10nKey::ScmGraphFoldLanes, + L10nKey::ScmGraphShowLanes, + L10nKey::ScmCheckoutCommit, + L10nKey::ScmCreateBranchHere, + L10nKey::ScmResetSoft, + L10nKey::ScmResetMixed, + L10nKey::ScmResetHard, L10nKey::ScmCommitDetailTitle, L10nKey::ScmCopyCommitSha, L10nKey::ScmCherryPick, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e23182a7..c26b0ebe 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -823,6 +823,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGraphFilterPlaceholder => "筛选提交…", L10nKey::ScmGraphAllBranches => "全部分支", L10nKey::ScmGraphEmpty => "还没有提交", + L10nKey::ScmGraphCurrentBranch => "当前分支", + L10nKey::ScmGraphFoldLanes => "隐藏泳道", + L10nKey::ScmGraphShowLanes => "显示泳道", + L10nKey::ScmCheckoutCommit => "检出此提交", + L10nKey::ScmCreateBranchHere => "在此创建分支…", + L10nKey::ScmResetSoft => "重置(保留暂存)", + L10nKey::ScmResetMixed => "重置(保留工作区)", + L10nKey::ScmResetHard => "重置(丢弃更改)", L10nKey::ScmCommitDetailTitle => "提交", L10nKey::ScmCopyCommitSha => "复制提交 SHA", L10nKey::ScmCherryPick => "拣选提交", diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 577dc434..f667f4b9 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() @@ -177,13 +197,16 @@ impl Theme { } } + /// One entry of the palette as a packed `0xRRGGBB`. + fn ansi(&self, i: usize) -> u32 { + let (r, g, b) = self.ansi16[i]; + (r as u32) << 16 | (g as u32) << 8 | b as u32 + } + pub fn semantics(&self) -> Semantics { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); - let ansi = |i: usize| -> u32 { - let (r, g, b) = self.ansi16[i]; - (r as u32) << 16 | (g as u32) << 8 | b as u32 - }; + let ansi = |i: usize| self.ansi(i); // An error line lands on a popover or a sidebar row as often as on the // window, and both of those fills sit a step toward the foreground. // Clear the floor on every surface the ink can be painted on, not just @@ -211,6 +234,44 @@ 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); + // Same three surfaces as `semantics`: the graph draws on the window in + // a floating panel, on the sidebar when the panel is docked, and on a + // popover in the commit detail view. + let surfaces = [bg, mix(bg, fg, 0.03), mix(bg, fg, 0.05)]; + let clear = |seed: u32| { + surfaces.iter().fold(seed, |ink, surface| { + legible_ink(*surface, ink, ACCENT_FLOOR) + }) + }; + let mut ink = [0u32; LANE_SLOTS]; + for (slot, seed) in SEEDS.iter().enumerate() { + ink[slot] = clear(self.ansi(*seed)); + } + 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); @@ -1205,6 +1266,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/scm/graph.rs b/src/ui/scm/graph.rs index 4bf0b334..79adb88c 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -7,50 +7,78 @@ //! message is the commit detail view's job, one click away. //! //! That is also VS Code's own reading of a sidebar graph, and it is why the -//! conventional-commit prefix is lifted out into a chip rather than left to -//! eat half the line. +//! conventional-commit prefix is lifted out into a chip rather than left to eat +//! half the line, and why the lane gutter folds away on request. //! -//! # Spike (G7·0) +//! # How it is drawn //! -//! What is below is deliberately a hard-coded three-row figure. It exists to -//! prove the four load-bearing claims of the rendering plan before any of the -//! real data is wired to it: that one absolutely-positioned canvas draws -//! correctly inside a scroll container, that `content_mask` gives usable -//! culling bounds, that the row `div`s underneath still receive hover and -//! click through the canvas above them, and that the height drag feels right. -//! The next commit replaces the fake rows with `CommitPage` and keeps the -//! shape. +//! 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, Bounds, Context, Corners, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, - SharedString, Window, canvas, div, fill, prelude::*, px, + 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::{ActiveTheme as _, h_flex, v_flex}; +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, 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 lane geometry reads better when the vertical pitch is -/// close to the lane pitch. +/// 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. const GRAPH_ROW_H: f32 = 20.; /// Horizontal distance between lane centres. const GRAPH_LANE_W: f32 = 12.; -/// Inset before the first lane centre, so lane 0 is not flush against the -/// panel edge. +/// 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.; const GRAPH_DOT_R: f32 = 3.; const GRAPH_LINE_W: f32 = 1.5; -/// Resting height of the history section, and the range the divider drags it -/// through. The maximum is a fraction of the panel rather than a constant: -/// the file list has to keep a usable share of a short window. +/// 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. const GRAPH_H_DEFAULT: f32 = 220.; const GRAPH_H_MIN: f32 = 88.; const GRAPH_H_MAX_RATIO: f32 = 0.65; @@ -58,13 +86,44 @@ 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. Folding the gutter +/// collapses it to a single column, which is worth about six characters of the +/// message — at this width, the difference between reading a subject and +/// reading its first word. +fn max_lanes(panel_w: f32, collapsed: bool) -> usize { + if collapsed { + return 1; + } + let fit = (panel_w * GRAPH_GUTTER_SHARE / 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 given, but it does that to 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 reasoning, same shape as `powerline_solid_edge`. +/// `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; @@ -72,181 +131,349 @@ fn snap(x: f32, scale: f32) -> f32 { (x * scale).round() / scale } -/// Centre of `lane`, in the canvas's own coordinate space. -fn lane_center_x(lane: u16, scale: f32) -> f32 { +/// 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 * lane as f32 + GRAPH_LANE_W / 2., + 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 type is exactly +/// the part that renders better as three coloured characters than as prose. +/// +/// 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 node ring, so a merge reads as a ring and not as a + /// disc with a hole punched through to whatever is under the panel. + 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. + if row.parents > 1 { + // A merge is a ring. It is the one row shape a reader scans for, + // and an outline reads at 6px where a second fill colour does not. + let r = GRAPH_DOT_R + 1.; + window.paint_quad(quad( + dot(r), + Corners::all(px(r)), + p.surface, + 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)), + p.surface, + 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 { + let mut c: Hsla = gpui::rgb(ink).into(); + c.a = 0.3; + window.paint_quad(fill(vline(x, y0, y0 + GRAPH_ROW_H), c)); + } else { + // Three steps rather than a gradient: a gradient would be a + // second `Background` kind for four pixels of ink. + for (step, alpha) in [0.5f32, 0.3, 0.15].into_iter().enumerate() { + let mut c: Hsla = gpui::rgb(ink).into(); + c.a = alpha; + let a = y0 + step as f32 * 3.; + window.paint_quad(fill(vline(x, a, a + 3.), c)); + } + } + } + } + + 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. + /// 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, + repo: &RepoKey, window: &mut Window, cx: &mut Context, ) -> Option { if !self.scm.graph.expanded { - return Some(self.graph_header(cx)); + // 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 max = (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, max); + 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); - // The spike's figure: a straight lane 0, a branch that opens on row 1 - // and merges back on row 2. Three dots, one elbow each way. - let mut rows: Vec<(u16, &'static str)> = vec![ - (0, "third commit on the trunk"), - (1, "a branch opens here"), - (0, "and merges back in"), - ]; - // Enough rows that the section actually scrolls, which is the only way - // to watch the culling window track the viewport. - for _ in 0..30 { - rows.push((0, "filler so the section scrolls")); - } - let lanes = 2u16; - let gutter = GRAPH_PAD_L + GRAPH_LANE_W * lanes as f32; - let scale = window.scale_factor(); - let line = cx.theme().accent; - let alt = cx.theme().warning; - let sf = cx.theme().secondary; - let fg = cx.theme().foreground; + let page = self.scm.graph.page.clone(); + let header = self.graph_header(repo, page.as_deref(), cx); + let search = self.graph_search(window, 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(), cx), + }; + let (backing, handle) = self.graph_resize(ceiling, cx); - let painted = Rc::new(StdCell::new(0usize)); - let seen_mask = Rc::new(StdCell::new(0.0f32)); - let clicks = self.scm.graph.selected.clone(); - - let body = div() - .relative() - .w_full() - .h(px(rows.len() as f32 * GRAPH_ROW_H)) - .child( - v_flex().children( - rows.iter() - .enumerate() - .map(|(i, (_, text))| self.graph_spike_row(i, text, cx)), - ), - ) - .child( - canvas(|_, _, _| (), { - let rows = rows.clone(); - let painted = painted.clone(); - let seen_mask = seen_mask.clone(); - move |bounds: Bounds, _, window: &mut Window, _| { - // Culling: the canvas is as tall as the whole - // content, so only the band the mask allows is - // worth iterating. - let mask = window.content_mask().bounds; - seen_mask.set(mask.size.height.as_f32()); - let top = bounds.origin.y.as_f32(); - 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 mut n = 0usize; - for (i, (lane, _)) in rows.iter().enumerate().skip(first).take(last - first) - { - let y0 = top + i as f32 * GRAPH_ROW_H; - let mid = y0 + GRAPH_ROW_H / 2.; - let cx0 = bounds.origin.x.as_f32() + lane_center_x(0, scale); - let cx1 = bounds.origin.x.as_f32() + lane_center_x(1, scale); - let c = if *lane == 0 { line } else { alt }; - // Lane 0 runs the full height of every band. - window.paint_quad(fill( - Bounds::from_corners( - gpui::point(px(cx0 - GRAPH_LINE_W / 2.), px(y0)), - gpui::point(px(cx0 + GRAPH_LINE_W / 2.), px(y0 + GRAPH_ROW_H)), - ), - line, - )); - n += 1; - // Row 1 opens lane 1 with an elbow, row 2 - // closes it with the mirror image. - if i == 1 { - window.paint_quad(fill( - Bounds::from_corners( - gpui::point(px(cx0), px(mid - GRAPH_LINE_W / 2.)), - gpui::point(px(cx1), px(mid + GRAPH_LINE_W / 2.)), - ), - alt, - )); - window.paint_quad(fill( - Bounds::from_corners( - gpui::point(px(cx1 - GRAPH_LINE_W / 2.), px(mid)), - gpui::point( - px(cx1 + GRAPH_LINE_W / 2.), - px(y0 + GRAPH_ROW_H), - ), - ), - alt, - )); - n += 2; - } - if i == 2 { - window.paint_quad(fill( - Bounds::from_corners( - gpui::point(px(cx1 - GRAPH_LINE_W / 2.), px(y0)), - gpui::point(px(cx1 + GRAPH_LINE_W / 2.), px(mid)), - ), - alt, - )); - window.paint_quad(fill( - Bounds::from_corners( - gpui::point(px(cx0), px(mid - GRAPH_LINE_W / 2.)), - gpui::point(px(cx1), px(mid + GRAPH_LINE_W / 2.)), - ), - alt, - )); - n += 2; - } - // The node. A rounded quad, not a path: quads - // get the SDF's analytic anti-aliasing, and a - // path would open a whole render pass. - let cxn = if *lane == 0 { cx0 } else { cx1 }; - window.paint_quad( - fill( - Bounds::from_corners( - gpui::point(px(cxn - GRAPH_DOT_R), px(mid - GRAPH_DOT_R)), - gpui::point(px(cxn + GRAPH_DOT_R), px(mid + GRAPH_DOT_R)), - ), - c, - ) - .corner_radii(Corners::all(px(GRAPH_DOT_R))), - ); - n += 1; - } - painted.set(n); - } - }) - .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(body); - - let (backing, handle) = self.graph_resize(max, cx); - let _ = clicks; Some( v_flex() .relative() @@ -254,67 +481,707 @@ impl Tty7App { .h(px(height)) .border_t_1() .border_color(cx.theme().border) - .bg(sf) - .text_color(fg) .child(backing) - .child(self.graph_header(cx)) - .child(scroller) + .child(header) + .children(search) + .children(naming) + .child(body) .child(handle) .into_any_element(), ) } - fn graph_header(&self, cx: &mut Context) -> AnyElement { - let expanded = self.scm.graph.expanded; - h_flex() - .id("scm-graph-header") - .flex_none() - .items_center() - .gap(px(6.)) - .h(px(24.)) - .px(px(CONTENT_INSET)) - .cursor_pointer() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(if expanded { - "▾ Graph (spike)" - } else { - "▸ Graph (spike)" - })) - .on_click(cx.listener(|this, _, _, cx| this.scm_toggle_graph(cx))) - .into_any_element() + /// Which refs the current settings walk from. + fn graph_scope(&self) -> GraphScope { + self.scm.graph.scope.clone() } - /// One interactive row. Deliberately an ordinary `div`: `Canvas::id` - /// returns `None` and it implements no interactivity, so it registers no - /// hitbox in prepaint — being drawn on top of these rows changes the - /// painting order and nothing about where a click lands. - fn graph_spike_row(&self, i: usize, text: &str, cx: &mut Context) -> AnyElement { - let sf = cx.theme().secondary; - let id = spike_id(i); - let selected = self.scm.graph.selected.as_deref() == Some(id.as_str()); + /// 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) { + // `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(); + let want = self.scm.graph.requested.max(GRAPH_PAGE); + 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); + if fresh || self.scm.graph.loading { + 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; + // A page that came back for a scope nobody is looking at any + // more is dropped rather than shown for one frame. + if let Some(page) = page { + this.scm.graph.page = Some(Arc::new(page)); + this.scm.graph.page_key = Some(key); + } + cx.notify(); + }, + ); + } + + /// 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) + } + + fn graph_search(&mut self, window: &mut Window, cx: &mut Context) -> Option { + if self.scm.graph.search.is_none() { + let input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder(t(L10nKey::ScmGraphFilterPlaceholder)) + }); + 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(); + } + }), + ); + self.scm.graph.search = Some(input); + } + let input = self.scm.graph.search.clone()?; + Some(self.panel_search(&input, cx)) + } +} + +impl Tty7App { + /// The scrolling list: rows underneath, one canvas over the gutter. + fn graph_body( + &mut self, + repo: &RepoKey, + page: &Arc, + query: Option<&str>, + cx: &mut Context, + ) -> AnyElement { + let panel_w = cx.global::().right_panel_width; + let cap = max_lanes(panel_w, self.scm.graph.lanes_collapsed); + 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: Vec = match query { + None => (0..page.commits.len()).collect(), + Some(q) => (0..page.commits.len()) + .filter(|i| matches_query(&page.commits[*i], q)) + .collect(), + }; + let more = query.is_none() && !page.complete; + let bands = rows.len() + usize::from(more); + + // 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().children( + rows.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 paint = GraphPaint { + page: page.clone(), + max_lanes: cap, + overflowing: page.max_lanes as usize > cap, + lanes: cx.global::().0, + surface: cx.theme().background, + 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 chip, 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(); + 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(GRAPH_PAD_L + GRAPH_LANE_W * 2. + 8.)) + .pl(px(gutter)) .pr(px(CONTENT_INSET)) - .text_size(px(12.)) - .when(selected, |d| d.bg(cx.theme().accent.opacity(0.28))) - .when(!selected, |d| d.hover(|s| s.bg(sf.opacity(0.9)))) .cursor_pointer() - .child(SharedString::from(text.to_string())) - .on_click(cx.listener(move |this, _, _, cx| { - this.scm.graph.selected = Some(spike_id(i)); + .when(selected, |d| d.bg(gpui::rgb(sf.selected))) + .when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover)))) + .children(prefix.map(|(kind, breaking)| self.graph_type_chip(kind, breaking, cx))) + .child( + 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))) + .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(); + let oid = oid.clone(); + move |this, _, _, cx| this.graph_open_commit(repo.clone(), oid.clone(), 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. + /// + /// Building that view is the commit-detail step's job; from here it is one + /// field, so that a click has exactly one meaning however the two land. + fn graph_open_commit(&mut self, repo: RepoKey, oid: String, cx: &mut Context) { + self.scm.graph.selected = Some(oid.clone()); + self.scm.detail = Some(crate::ui::scm::state::CommitDetailView { + repo, + oid, + loading: true, + }); + cx.notify(); + } +} + +/// 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) + )) +} + +/// The semantic slot a conventional-commit type draws from. +/// +/// Reusing the semantic ramp rather than inventing a palette: `feat` is the +/// same green as a success anywhere else in the UI, `fix` the same red as a +/// danger, and all of them have already been walked to a contrast floor on +/// every surface. Anything unrecognised is muted, so a repository with its own +/// vocabulary gets a neutral chip rather than an arbitrary colour. +fn type_tone(kind: &str, breaking: bool, cx: &gpui::App) -> (Hsla, Hsla) { + let theme = cx.theme(); + // A `!` is the one thing in a subject line worth shouting about, whatever + // the type in front of it says. + if breaking { + return (theme.danger.opacity(0.20), theme.danger); + } + let ink = match kind { + "feat" => theme.success, + "fix" => theme.danger, + "perf" | "revert" => theme.warning, + "docs" => theme.info, + _ => theme.muted_foreground, + }; + (ink.opacity(0.16), ink) +} + +impl Tty7App { + /// The prefix, as three or four coloured characters. + fn graph_type_chip(&self, kind: &str, breaking: bool, cx: &mut Context) -> AnyElement { + let (bg, fg) = type_tone(kind, breaking, cx); + div() + .flex_none() + .px(px(3.)) + .rounded(px(3.)) + .bg(bg) + .text_size(px(9.5)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(fg) + .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. + 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, + ), + _ => ( + theme.muted.opacity(0.9), + 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)), + }; + 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() + .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| { + let now = this.scm.graph.requested.max(GRAPH_PAGE); + this.scm.graph.requested = now.saturating_add(GRAPH_PAGE); cx.notify(); })) .into_any_element() } +} - /// `right_panel_resize` rotated 90°: the same canvas-remembers-bounds plus - /// `Rc` pair, dragging the top edge of the history section instead of - /// the left edge of the panel. - fn graph_resize(&self, max: f32, cx: &mut Context) -> (AnyElement, AnyElement) { +impl Tty7App { + /// The section's own title row: fold, count, gutter toggle, scope picker. + 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 count = page.map(|p| match p.complete { + true => p.commits.len().to_string(), + false => format!("{}+", p.commits.len()), + }); + let collapsed = self.scm.graph.lanes_collapsed; + + h_flex() + .flex_none() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .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( + 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))), + ) + .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( + div() + .id("scm-graph-lanes") + .flex_none() + .size(px(18.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .cursor_pointer() + .when(collapsed, |d| d.bg(cx.theme().secondary)) + .hover(|s| s.bg(cx.theme().secondary)) + .child( + Icon::new(match collapsed { + true => IconName::ChevronRight, + false => IconName::ChevronLeft, + }) + .size(px(11.)) + .text_color(muted), + ) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(match collapsed { + true => t(L10nKey::ScmGraphShowLanes), + false => t(L10nKey::ScmGraphFoldLanes), + }) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.scm.graph.lanes_collapsed = !this.scm.graph.lanes_collapsed; + cx.notify(); + })), + ) + .child( + Button::new("scm-graph-scope") + .ghost() + .xsmall() + .h(px(18.)) + .rounded(px(4.)) + .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( { @@ -337,9 +1204,10 @@ impl Tty7App { let Some(b) = container.get() else { return; }; - let bottom = b.origin.y + b.size.height; - let raw = (bottom - ev.position.y).as_f32(); - height.set(raw.clamp(GRAPH_H_MIN, max)); + // 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(); } }); @@ -371,12 +1239,11 @@ impl Tty7App { .h(px(GRAPH_HANDLE_H)) .flex() .items_center() - .justify_center() .cursor_row_resize() .child( div() - .h(px(1.)) .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)), ) @@ -393,7 +1260,547 @@ impl Tty7App { } } -/// Stand-in for the sha the real rows will be keyed by. -fn spike_id(i: usize) -> String { - format!("spike-{i}") +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(); + } + + 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.)) + .child(gpui_component::input::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.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"); + } + // A single column is the folded gutter, and it 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_and_folds_to_one() { + // 260px is the default panel; 216px is about as narrow as it gets. + assert_eq!(max_lanes(260., false), 6); + assert_eq!(max_lanes(216., false), 5); + assert_eq!(max_lanes(160., false), 4); + assert_eq!(max_lanes(120., false), 3); + // 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., false), GRAPH_MIN_LANES); + // And above the palette it stops growing, or two columns would share a + // colour. + assert_eq!(max_lanes(4000., false), GRAPH_MAX_LANES); + assert!(max_lanes(4000., false) <= LANE_SLOTS); + assert_eq!(max_lanes(260., true), 1); + } + + #[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)), "修复终端右键菜单的本地化") + ); + } + + /// `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 the_lane_gutter_folds_and_stays_folded(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // Session state rather than config: unlike the section's own fold, this + // is a reading posture for one repository's shape, not a preference. + assert!(!app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); + app.update(&mut vcx, |app, cx| { + app.scm.graph.lanes_collapsed = true; + cx.notify(); + }); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); + } + + #[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); + + app.update(&mut vcx, |app, cx| { + app.graph_open_commit(repo(), "deadbeef".into(), 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.loading); + } + + /// 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())); + } + + #[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")); + } + + #[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. +#[cfg(test)] +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 index ff6da7c7..e64b83e4 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,18 +4,15 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// What is left unused is what the graph and the commit detail view will call: -// `relative_time` has no row to date yet, and `status_rank` is the file tree's -// to use. Both allows come off with the step that wires them up. +// What is left unused is what the commit detail view will call; `status_rank` +// is the file tree's to use. Those allows come off with the step that wires +// them up — the graph's did, with this one. pub(crate) mod actions; #[allow(dead_code)] pub(crate) mod detail; -#[allow(dead_code)] pub(crate) mod graph; pub(crate) mod panel; -#[allow(dead_code)] pub(crate) mod path; -#[allow(dead_code)] pub(crate) mod state; #[allow(dead_code)] pub(crate) mod status; diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 96880939..e66b0f5f 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -12,9 +12,11 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::Arc; use gpui::Entity; use gpui_component::input::InputState; +use tty7_core::core::git::log::{CommitPage, GraphScope}; use tty7_core::core::git::status::HeadState; use crate::ui::host_ops::HostId; @@ -156,10 +158,30 @@ pub(crate) struct GraphState { /// and shifts under you when a ref moves between pages. pub(crate) requested: usize, pub(crate) loading: bool, - /// Filter box. Like `commit_input`, created on first render. + /// 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)>, + /// Fold the lane gutter down to a single column. Worth about six + /// characters of the message, which at this width is the difference + /// between reading a subject and reading its first word. + pub(crate) lanes_collapsed: bool, + /// 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>, - /// `refs/heads/...` the graph is restricted to; empty means all refs. - pub(crate) branch_filter: Option, + pub(crate) search_sub: Option, + /// 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, diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 18bf7c06..1a9d911f 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -283,6 +283,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(); From 25bfe67a7b8dc173efb4921d6e47fc83c5ed4361 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:45:58 +0800 Subject: [PATCH 28/36] fix(scm): budget the graph gutter's insets, not just its lanes The share was measured against the lane strip alone, so a 260px panel asked for six lanes and an 84px gutter out of a 78px budget. Five is what fits, and what the width was chosen for. --- src/ui/scm/graph.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 79adb88c..6a644dd0 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -101,7 +101,9 @@ fn max_lanes(panel_w: f32, collapsed: bool) -> usize { if collapsed { return 1; } - let fit = (panel_w * GRAPH_GUTTER_SHARE / GRAPH_LANE_W).floor(); + // 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; } @@ -1415,10 +1417,20 @@ mod tests { #[test] fn the_gutter_narrows_with_the_panel_and_folds_to_one() { // 260px is the default panel; 216px is about as narrow as it gets. - assert_eq!(max_lanes(260., false), 6); - assert_eq!(max_lanes(216., false), 5); - assert_eq!(max_lanes(160., false), 4); - assert_eq!(max_lanes(120., false), 3); + assert_eq!(max_lanes(260., false), 5); + assert_eq!(max_lanes(216., false), 4); + assert_eq!(max_lanes(320., false), 6); + assert_eq!(max_lanes(160., false), 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, false); + 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., false), GRAPH_MIN_LANES); From 96c997ad594f1a724b86608f74801d7d8855db32 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:35:54 +0800 Subject: [PATCH 29/36] fix(scm): keep the row buttons on screen, and settle the panel's surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real defects and a design pass, all found accepting the panel against a running app. The hover buttons on a file row erased themselves the instant the pointer reached them. The strip called `occlude()`, and gpui's `Frame::hit_test` stops at the first `BlockMouse` hitbox and drops every hitbox inserted before it — which includes the row's own, because a parent prepaints before its children. So the row stopped counting as hovered, `group_hover` stopped applying, and `Interactivity::paint` returned early on `Visibility::Hidden` before drawing either the backing or the buttons. The tooltip outlived them because it is an `on_hover` listener armed on the last frame that painted, which is why what was left on screen read as a grey box where the buttons should have been. Replaced with `on_any_mouse_down` and `stop_propagation` — the idiom `switcher.rs` already ships — and two gpui tests now fail if `occlude()` comes back. The commit-detail view drew "No files changed" above "Loading…" while its read was still out, and the graph's new-branch field was the only `Input` in the application without `.appearance(false)`, so it wore gpui-component's default border. The rest is the panel's visual language, which had drifted into tty7's dialog vocabulary. `bg(theme.input)` occurred exactly once in the whole application and `.primary()` only ever appears in modals and the settings page, yet the commit box was a filled bordered field with an accent focus ring and the commit button a filled slab — in a panel where nothing else is outlined and separation is carried by surface and space. The commit area is now two soft rounded fills, both from `field_fill`, at half the surface ramp's first rung: `hover` is what a row wears for the moment a pointer is on it, and a field that wears its fill permanently is the loudest thing on an idle panel at that strength. Focus takes the whole rung instead of a ring. The split button lights as one shape rather than one end — it has no outline around either half and a seam one pixel wide, so half a lit pill read as a paint bug — and its halves paint nothing themselves in any state. That last part is not only about hover: gpui-component resolves a custom variant's *selected* paint from its `active` slot, and a dropdown holds its trigger selected for as long as the menu is open, which parked a block on the chevron for the whole time the menu was being read. Smaller things in the same pass. The graph's conventional-commit prefix is inline muted text rather than a coloured pill, which stops a second colour column competing with the lane gutter beside it and un-ragged the left edge of the subjects. Commit-detail refs no longer paint `theme.accent` at full opacity under muted text — that is the system's loudest neutral fill, and it made an ordinary `origin/main` shout over the HEAD chip it was meant to defer to. The commit button is compact and right-aligned beside a staged-file count, the history filter hides behind a toggle that takes its query with it when it closes, and the detail view says how many lines a commit moved. `TILE_SIZE_XS` and `TILE_GLYPH_XS` moved to `app.rs`, so the SFTP and port-forward panels no longer import tile sizes from the Source Control module. The lane-gutter fold is gone. It bought about six characters of subject width, but folding the lanes away leaves a list rather than a graph, so nobody would ever press it. --- src/ui/app.rs | 14 + src/ui/forwards.rs | 26 +- src/ui/i18n/en.rs | 10 +- src/ui/i18n/ja.rs | 10 +- src/ui/i18n/mod.rs | 7 +- src/ui/i18n/zh.rs | 10 +- src/ui/right_panel.rs | 102 +++++- src/ui/scm/detail.rs | 473 ++++++++++++++++++++++++---- src/ui/scm/graph.rs | 578 +++++++++++++++++++++++++--------- src/ui/scm/panel.rs | 712 ++++++++++++++++++++++++++++++++++-------- src/ui/scm/state.rs | 4 - src/ui/sftp.rs | 25 +- 12 files changed, 1593 insertions(+), 378 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 01717d14..5aed257e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -75,8 +75,22 @@ pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.; pub(crate) const TILE_SIZE: f32 = 32.; pub(crate) const TILE_GLYPH: f32 = 13.; pub(crate) const TILE_SIZE_SM: f32 = 24.; +/// The small tile is both a smaller box and a smaller glyph than +/// [`TILE_GLYPH`]: it lives beside the right panel's 12px rows, where a 13px +/// glyph would out-weigh the text it sits next to. The padding below is +/// derived from it, so the glyph's optical edge still lands at `CONTENT_INSET` +/// and rows keep their alignment. pub(crate) const TILE_GLYPH_SM: f32 = 11.; +/// 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.; diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 45ec7faa..2e09db31 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}; impl Tty7App { @@ -39,6 +39,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( @@ -80,6 +84,10 @@ impl Tty7App { ) -> Option { self.ssh_close_confirm?; let theme = cx.theme(); + // A modal card centred over the window, not panel furniture: it keeps + // gpui's own defaults (a 16px title over a `text_sm` body) because a + // dialog is read on its own, with nothing beside it to be out of step + // with. The panel's ramp deliberately stops at the panel. let card = v_flex() .w(px(360.)) .gap_3() @@ -142,15 +150,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/i18n/en.rs b/src/ui/i18n/en.rs index 7455fd1e..e063dfd7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -812,7 +812,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmGroupStaged => "Staged Changes", L10nKey::ScmGroupChanges => "Changes", L10nKey::ScmGroupUntracked => "Untracked", - L10nKey::ScmCommitPlaceholder => "Message", + L10nKey::ScmCommitPlaceholder => "Say what changed…", L10nKey::ScmCommitButton => "Commit", L10nKey::ScmCommitAllButton => "Commit All", L10nKey::ScmCommitAmendButton => "Commit (Amend)", @@ -845,14 +845,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmCreateBranch => "Create Branch…", L10nKey::ScmSearchBranches => "Search Branches…", L10nKey::ScmStashAndSwitch => "Stash & Switch", - L10nKey::ScmGraphTitle => "Graph", + 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::ScmGraphFoldLanes => "Hide Lanes", - L10nKey::ScmGraphShowLanes => "Show Lanes", L10nKey::ScmCheckoutCommit => "Checkout Commit", L10nKey::ScmCreateBranchHere => "Create Branch Here…", L10nKey::ScmResetSoft => "Reset (Soft)", @@ -1295,6 +1293,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } 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…", @@ -1400,6 +1399,9 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (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::PanelUntracked, "zero") => "0 untracked", (L10nKey::PanelUntracked, "one") => "1 untracked", (L10nKey::PanelUntracked, "other") => "{count} untracked", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c3cb5f93..1bec17cf 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -862,7 +862,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGroupStaged => "ステージされた変更", L10nKey::ScmGroupChanges => "変更", L10nKey::ScmGroupUntracked => "未追跡", - L10nKey::ScmCommitPlaceholder => "コミットメッセージ", + L10nKey::ScmCommitPlaceholder => "何を変えたか書いてみましょう…", L10nKey::ScmCommitButton => "コミット", L10nKey::ScmCommitAllButton => "すべてコミット", L10nKey::ScmCommitAmendButton => "コミット(修正)", @@ -895,14 +895,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmCreateBranch => "ブランチを作成…", L10nKey::ScmSearchBranches => "ブランチを検索…", L10nKey::ScmStashAndSwitch => "スタッシュして切り替え", - L10nKey::ScmGraphTitle => "グラフ", + L10nKey::ScmGraphTitle => "履歴", L10nKey::ScmGraphLoadMore => "さらに読み込む", L10nKey::ScmGraphFilterPlaceholder => "コミットを絞り込む…", L10nKey::ScmGraphAllBranches => "すべてのブランチ", L10nKey::ScmGraphEmpty => "まだコミットがありません", L10nKey::ScmGraphCurrentBranch => "現在のブランチ", - L10nKey::ScmGraphFoldLanes => "レーンを隠す", - L10nKey::ScmGraphShowLanes => "レーンを表示", L10nKey::ScmCheckoutCommit => "このコミットをチェックアウト", L10nKey::ScmCreateBranchHere => "ここにブランチを作成…", L10nKey::ScmResetSoft => "リセット(ソフト)", @@ -1340,6 +1338,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::PanelUntracked => "未追跡 {count}", L10nKey::ScmFilesChanged => "{count} 個のファイルが変更されました", + L10nKey::ScmStagedFileCount => "{count} 個のファイルがステージされました", L10nKey::AppMenuAbout => "tty7 について", L10nKey::AppMenuCheckForUpdates => "アップデートを確認…", L10nKey::AppMenuSettings => "設定…", @@ -1443,6 +1442,9 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::ScmFilesChanged, "zero") => "変更されたファイルはありません", (L10nKey::ScmFilesChanged, "one") => "1 個のファイルが変更されました", (L10nKey::ScmFilesChanged, "other") => "{count} 個のファイルが変更されました", + (L10nKey::ScmStagedFileCount, "zero") => "ステージされた変更はありません", + (L10nKey::ScmStagedFileCount, "one") => "1 個のファイルがステージされました", + (L10nKey::ScmStagedFileCount, "other") => "{count} 個のファイルがステージされました", (L10nKey::PanelUntracked, "zero") => "未追跡 0", (L10nKey::PanelUntracked, "one") => "未追跡 1", (L10nKey::PanelUntracked, "other") => "未追跡 {count}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f6b779d1..9991c299 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -677,8 +677,6 @@ pub enum L10nKey { ScmGraphAllBranches, ScmGraphEmpty, ScmGraphCurrentBranch, - ScmGraphFoldLanes, - ScmGraphShowLanes, ScmCheckoutCommit, ScmCreateBranchHere, ScmResetSoft, @@ -717,6 +715,8 @@ pub enum L10nKey { ScmOpAm, ScmSwitchRepository, ScmFilesChanged, + /// The staged-file count that sits to the left of the Commit button. + ScmStagedFileCount, WindowStop, WindowDelete, WindowThisWorkspace, @@ -1150,8 +1150,6 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmGraphAllBranches, L10nKey::ScmGraphEmpty, L10nKey::ScmGraphCurrentBranch, - L10nKey::ScmGraphFoldLanes, - L10nKey::ScmGraphShowLanes, L10nKey::ScmCheckoutCommit, L10nKey::ScmCreateBranchHere, L10nKey::ScmResetSoft, @@ -2242,6 +2240,7 @@ mod tests { 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 3e83ff57..2a47c128 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -787,7 +787,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGroupStaged => "暂存的更改", L10nKey::ScmGroupChanges => "更改", L10nKey::ScmGroupUntracked => "未跟踪", - L10nKey::ScmCommitPlaceholder => "提交信息", + L10nKey::ScmCommitPlaceholder => "写点什么改了…", L10nKey::ScmCommitButton => "提交", L10nKey::ScmCommitAllButton => "提交全部", L10nKey::ScmCommitAmendButton => "提交(修订)", @@ -818,14 +818,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmCreateBranch => "新建分支…", L10nKey::ScmSearchBranches => "搜索分支…", L10nKey::ScmStashAndSwitch => "贮藏并切换", - L10nKey::ScmGraphTitle => "提交图", + L10nKey::ScmGraphTitle => "提交历史", L10nKey::ScmGraphLoadMore => "加载更多", L10nKey::ScmGraphFilterPlaceholder => "筛选提交…", L10nKey::ScmGraphAllBranches => "全部分支", L10nKey::ScmGraphEmpty => "还没有提交", L10nKey::ScmGraphCurrentBranch => "当前分支", - L10nKey::ScmGraphFoldLanes => "隐藏泳道", - L10nKey::ScmGraphShowLanes => "显示泳道", L10nKey::ScmCheckoutCommit => "检出此提交", L10nKey::ScmCreateBranchHere => "在此创建分支…", L10nKey::ScmResetSoft => "重置(保留暂存)", @@ -1234,6 +1232,7 @@ pub fn translate_zh(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 => "设置…", @@ -1335,6 +1334,9 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::ScmFilesChanged, "zero") => "没有文件改动", (L10nKey::ScmFilesChanged, "one") => "1 个文件改动", (L10nKey::ScmFilesChanged, "other") => "{count} 个文件改动", + (L10nKey::ScmStagedFileCount, "zero") => "没有暂存的更改", + (L10nKey::ScmStagedFileCount, "one") => "已暂存 1 个文件", + (L10nKey::ScmStagedFileCount, "other") => "已暂存 {count} 个文件", (L10nKey::PanelUntracked, "zero") => "0 个未跟踪文件", (L10nKey::PanelUntracked, "one") => "1 个未跟踪文件", (L10nKey::PanelUntracked, "other") => "{count} 个未跟踪文件", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index f5e36ec0..5120bbb0 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -20,6 +20,46 @@ pub(crate) const MAX_WIDTH_RATIO: f32 = 0.5; const RESIZE_HANDLE_WIDTH: f32 = 8.; +// 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. +// +/// Primary content: row text, values, names, empty-state prose. +pub(crate) const PANEL_TEXT: f32 = 12.; +/// The panel's own title heading. A half-step under the content beneath it, so +/// it caps the panel without competing with it — the SEMIBOLD weight and the +/// caps are what make it read as a title, not the size. +pub(crate) const PANEL_TEXT_TITLE: f32 = 11.5; +/// Secondary: directory paths, bylines, hints, counts. One notch below the +/// content it hangs off, close enough to stay readable. +pub(crate) const PANEL_TEXT_SECONDARY: f32 = 11.; +/// The smallest step, for marks rather than prose: group headers (SEMIBOLD, +/// uppercased), git status letters, and the mono tokens that sit in pills — +/// pids, ports. +pub(crate) const PANEL_TEXT_META: f32 = 10.5; + +/// gpui lays text out at its default `phi` line height, so one line of +/// [`PANEL_TEXT`] measures `round(12 × 1.618) = 19px` before any padding. One +/// pixel on each side is all the key/value rows need to stop touching: 21px. +const ROW_PAD_Y: f32 = 1.; + +/// 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) procs_pane: Option, @@ -255,16 +295,21 @@ 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(px(11.5)) + .text_size(px(PANEL_TEXT_TITLE)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().secondary_foreground) .child(text.to_uppercase()), ) .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(px(11.)) + .text_size(px(PANEL_TEXT_SECONDARY)) .font_family(cx.theme().mono_font_family.clone()) .text_color(cx.theme().muted_foreground.opacity(0.75)) .child(c), @@ -294,8 +339,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) @@ -342,12 +389,15 @@ impl Tty7App { .px(px(CONTENT_INSET)) .py(px(4.)) .gap(px(3.)) - .text_size(px(12.)) + // The first line is the only thing in the panel when this renders, + // so it is content, not an aside: it stays on the primary step. + // The hint under it is the aside, and drops to secondary. + .text_size(px(PANEL_TEXT)) .text_color(muted) .child(text.to_string()) .children(hint.map(|h| { div() - .text_size(px(11.)) + .text_size(px(PANEL_TEXT_SECONDARY)) .text_color(muted.opacity(0.75)) .child(h.to_string()) })) @@ -430,8 +480,8 @@ impl Tty7App { h_flex() .items_baseline() .gap(px(9.)) - .py(px(1.)) - .text_size(px(12.)) + .py(px(ROW_PAD_Y)) + .text_size(px(PANEL_TEXT)) .child( div() .flex_none() @@ -531,8 +581,11 @@ 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(px(10.5)) + .text_size(px(PANEL_TEXT_META)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) .child(text.to_uppercase()), @@ -559,7 +612,7 @@ impl Tty7App { .min_w_0() .truncate() .pl(px(f32::from(p.depth) * 10.)) - .text_size(px(12.)) + .text_size(px(PANEL_TEXT)) .font_family(mono.clone()) .text_color(if p.foreground { cx.theme().foreground @@ -607,7 +660,7 @@ impl Tty7App { .flex_1() .min_w_0() .truncate() - .text_size(px(12.)) + .text_size(px(PANEL_TEXT)) .font_family(mono.clone()) .text_color(cx.theme().muted_foreground) .child(p.name.clone()), @@ -741,12 +794,27 @@ 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(px(10.5)) + .text_size(px(PANEL_TEXT_META)) .font_family(mono.clone()) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(color) @@ -754,6 +822,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, @@ -766,7 +842,7 @@ pub(crate) fn info_chip( .py(px(1.5)) .rounded(px(4.)) .bg(bg) - .text_size(px(10.5)) + .text_size(px(PANEL_TEXT_META)) .font_family(mono.clone()) .text_color(fg) .child(text.to_string()) diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index 8ffe877d..40b4d969 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -12,7 +12,20 @@ //! This is also where the panel pays back what the graph gave up. A history //! row has about 26 characters beside its lanes and this repository's subjects //! run to a median of 64, so the graph shows shape and this shows text: the -//! whole subject, the body, every ref, the parents, and the files. +//! 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; @@ -33,6 +46,16 @@ 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.; @@ -45,6 +68,44 @@ const BODY_LINES: usize = 4; /// 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. /// @@ -89,9 +150,13 @@ impl Tty7App { let mono = cx.theme().mono_font_family.clone(); let muted = cx.theme().muted_foreground; - // Each section insets itself rather than sharing one on the column: - // `panel_subtitle` applies `CONTENT_INSET` of its own, and an outer - // inset would push it eight pixels right of the rows beneath it. + // 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)); @@ -112,7 +177,7 @@ impl Tty7App { div() .px(px(CONTENT_INSET)) .py(px(4.)) - .text_size(px(12.)) + .text_size(px(SECONDARY_SIZE)) .text_color(muted) .child(if detail.loaded { t(L10nKey::ScmCommitNotFound) @@ -189,6 +254,14 @@ impl Tty7App { /// 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, @@ -196,6 +269,8 @@ impl Tty7App { 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.)) @@ -208,16 +283,21 @@ impl Tty7App { .gap(px(2.)) .px(px(4.)) .py(px(1.)) - .rounded_md() + .rounded(px(4.)) .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) + .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( - Icon::new(IconName::ChevronLeft) - .small() - .text_color(cx.theme().muted_foreground), - ) - .child(div().text_xs().child(t(L10nKey::ScmBackToChanges))), + div() + .text_size(px(SUBJECT_SIZE)) + .text_color(muted) + .child(t(L10nKey::ScmBackToChanges)), + ), ) .child(div().flex_1().min_w_0()) .child( @@ -226,10 +306,11 @@ impl Tty7App { .flex_none() .px(px(4.)) .py(px(1.)) - .rounded_md() + .rounded(px(4.)) .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) - .text_size(px(13.)) + .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)) @@ -258,25 +339,33 @@ impl Tty7App { .pb(px(4.)) .gap(px(3.)) .child( - // Wrapping, not truncating: this view exists because the - // graph row could only show the first 26 characters. + // 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(12.)) - .font_weight(gpui::FontWeight::MEDIUM) + .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(11.)) + .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(11.5)) + .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())), @@ -288,7 +377,7 @@ impl Tty7App { .w_full() .py(px(1.)) .cursor_pointer() - .text_size(px(11.)) + .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() { @@ -311,6 +400,27 @@ impl Tty7App { /// /// 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, @@ -330,25 +440,32 @@ impl Tty7App { let mut row = h_flex() .flex_wrap() .items_center() - .gap(px(4.)) + // 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 { - // The same three colours the graph's chips use: a tag is yellow - // because a tag is yellow everywhere in git, HEAD is emphasised, - // and everything else is quiet. - let (bg, color) = match deco.kind { - RefKind::Tag => (warning.opacity(0.16), warning), - _ if deco.is_head => (accent.opacity(0.28), fg), - _ => (accent, muted), - }; - row = row.child(info_chip(&deco.short, bg, color, mono)); + 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, @@ -359,6 +476,11 @@ impl Tty7App { 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() @@ -367,8 +489,8 @@ impl Tty7App { .pb(px(4.)) .child( div() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) + .text_size(px(SECONDARY_SIZE)) + .text_color(muted) .child(t(L10nKey::ScmCommitParents)), ); for parent in &commit.parents { @@ -377,13 +499,14 @@ impl Tty7App { row = row.child( div() .id(SharedString::from(format!("scm-detail-parent-{parent}"))) - .px(px(3.)) + .px(px(4.)) + .py(px(1.)) .rounded(px(4.)) .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) - .text_size(px(11.)) + .hover(|s| s.bg(hover_bg)) + .text_size(px(TOKEN_SIZE)) .font_family(mono.clone()) - .text_color(cx.theme().info) + .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. @@ -395,6 +518,11 @@ impl Tty7App { 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, @@ -402,18 +530,10 @@ impl Tty7App { mono: &SharedString, cx: &mut Context, ) -> AnyElement { - let files = detail.files.clone().unwrap_or_default(); - let list = v_flex().child(self.panel_subtitle( - &t_plural(L10nKey::ScmFilesChanged, files.len(), &[]), - true, - None, - cx, - )); - if detail.files.is_none() { - return list - .child(self.detail_note(t(L10nKey::PanelLoading).to_string(), cx)) - .into_any_element(); - } + let Some(files) = detail.files.clone() else { + return self.detail_note(t(L10nKey::PanelLoading).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 @@ -427,8 +547,8 @@ impl Tty7App { }), }; // 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. + // `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)); @@ -436,6 +556,81 @@ impl Tty7App { 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: @@ -451,7 +646,7 @@ impl Tty7App { mono: &SharedString, cx: &mut Context, ) -> AnyElement { - let sf = cx.global::().sidebar; + 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) @@ -490,6 +685,20 @@ impl Tty7App { .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 happen to be `right_panel`'s `PANEL_TEXT` and + // `PANEL_TEXT_SECONDARY`; 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 { @@ -518,13 +727,66 @@ impl Tty7App { div() .px(px(CONTENT_INSET)) .py(px(3.)) - .text_size(px(11.)) + .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 { @@ -550,6 +812,7 @@ fn now_unix() -> 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 { @@ -603,6 +866,72 @@ mod tests { 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. @@ -640,6 +969,13 @@ mod detail_gpui_tests { /// 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"]); @@ -662,6 +998,8 @@ mod detail_gpui_tests { "one\ntwo\nthree\nfour\nfive\nsix", ], ); + git(&root, &["branch", "sidequest"]); + git(&root, &["tag", "v1.0.0"]); root } @@ -745,6 +1083,19 @@ mod detail_gpui_tests { 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(); @@ -754,6 +1105,20 @@ mod detail_gpui_tests { "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()); diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 2c4dddc0..f4e9e980 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -1,14 +1,31 @@ //! The history section at the foot of the panel. //! -//! Its job is shape, not text. 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 -//! message is the commit detail view's job, one click away. +//! 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 lifted out into a chip rather than left to eat -//! half the line, and why the lane gutter folds away on request. +//! 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 //! @@ -51,8 +68,28 @@ 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.; +/// 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.; @@ -61,6 +98,10 @@ const GRAPH_LANE_W: f32 = 12.; 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; @@ -79,6 +120,13 @@ 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; @@ -93,14 +141,8 @@ 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. Folding the gutter -/// collapses it to a single column, which is worth about six characters of the -/// message — at this width, the difference between reading a subject and -/// reading its first word. -fn max_lanes(panel_w: f32, collapsed: bool) -> usize { - if collapsed { - return 1; - } +/// 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(); @@ -150,8 +192,9 @@ fn gutter_width(max_lanes: usize) -> f32 { /// /// 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 type is exactly -/// the part that renders better as three coloured characters than as prose. +/// 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 @@ -263,8 +306,12 @@ struct GraphPaint { max_lanes: usize, overflowing: bool, lanes: Lanes, - /// The fill behind a node ring, so a merge reads as a ring and not as a - /// disc with a hole punched through to whatever is under the panel. + /// 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, /// Whether a "load more" band follows the last row. more: bool, @@ -366,10 +413,14 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { }; // 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. + // 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. if row.parents > 1 { // A merge is a ring. It is the one row shape a reader scans for, - // and an outline reads at 6px where a second fill colour does not. + // 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), @@ -408,17 +459,20 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { let ink = column_ink(column, *lane, p.max_lanes, p.overflowing, &p.lanes); let x = cx_of(column); if p.more { - let mut c: Hsla = gpui::rgb(ink).into(); - c.a = 0.3; - window.paint_quad(fill(vline(x, y0, y0 + GRAPH_ROW_H), c)); + 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 mut c: Hsla = gpui::rgb(ink).into(); - c.a = alpha; - let a = y0 + step as f32 * 3.; - window.paint_quad(fill(vline(x, a, a + 3.), c)); + let a = y0 + step as f32 * STEP; + window.paint_quad(fill( + vline(x, a, a + STEP), + Hsla::from(gpui::rgb(ink)).opacity(alpha), + )); } } } @@ -464,7 +518,7 @@ impl Tty7App { let page = self.scm.graph.page.clone(); let header = self.graph_header(repo, page.as_deref(), cx); - let search = self.graph_search(window, 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 { @@ -554,25 +608,57 @@ impl Tty7App { (!text.is_empty()).then_some(text) } - fn graph_search(&mut self, window: &mut Window, cx: &mut Context) -> Option { - if self.scm.graph.search.is_none() { - let input = cx.new(|cx| { - gpui_component::input::InputState::new(window, cx) - .placeholder(t(L10nKey::ScmGraphFilterPlaceholder)) - }); - 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(); - } - }), - ); - self.scm.graph.search = Some(input); - } + /// 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 { @@ -585,7 +671,7 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let panel_w = cx.global::().right_panel_width; - let cap = max_lanes(panel_w, self.scm.graph.lanes_collapsed); + let cap = max_lanes(panel_w); let gutter = gutter_width(cap); let now = crate::ui::home::now_secs() as i64; @@ -623,7 +709,10 @@ impl Tty7App { max_lanes: cap, overflowing: page.max_lanes as usize > cap, lanes: cx.global::().0, - surface: cx.theme().background, + // 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(cx.global::().sidebar.base).into(), more, }; stack = stack.child( @@ -656,7 +745,7 @@ impl Tty7App { /// 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 chip, message, + /// 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. @@ -671,6 +760,7 @@ impl Tty7App { ) -> 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); @@ -686,19 +776,42 @@ impl Tty7App { .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)))) - .children(prefix.map(|(kind, breaking)| self.graph_type_chip(kind, breaking, cx))) + // 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( - div() + h_flex() .flex_1() .min_w(px(0.)) - .truncate() - .text_size(px(12.)) - .text_color(cx.theme().foreground) - .child(SharedString::from(subject.to_string())), + .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() @@ -780,42 +893,53 @@ fn commit_tooltip(commit: &Commit, now: i64) -> SharedString { )) } -/// The semantic slot a conventional-commit type draws from. +/// What ink a conventional-commit prefix is set in. /// -/// Reusing the semantic ramp rather than inventing a palette: `feat` is the -/// same green as a success anywhere else in the UI, `fix` the same red as a -/// danger, and all of them have already been walked to a contrast floor on -/// every surface. Anything unrecognised is muted, so a repository with its own -/// vocabulary gets a neutral chip rather than an arbitrary colour. -fn type_tone(kind: &str, breaking: bool, cx: &gpui::App) -> (Hsla, Hsla) { +/// 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(); - // A `!` is the one thing in a subject line worth shouting about, whatever - // the type in front of it says. - if breaking { - return (theme.danger.opacity(0.20), theme.danger); + match breaking { + true => theme.danger, + false => theme.muted_foreground, } - let ink = match kind { - "feat" => theme.success, - "fix" => theme.danger, - "perf" | "revert" => theme.warning, - "docs" => theme.info, - _ => theme.muted_foreground, - }; - (ink.opacity(0.16), ink) } impl Tty7App { - /// The prefix, as three or four coloured characters. - fn graph_type_chip(&self, kind: &str, breaking: bool, cx: &mut Context) -> AnyElement { - let (bg, fg) = type_tone(kind, breaking, cx); + /// 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() - .px(px(3.)) - .rounded(px(3.)) - .bg(bg) - .text_size(px(9.5)) - .font_family(cx.theme().mono_font_family.clone()) - .text_color(fg) + .text_size(px(12.)) + .text_color(type_tone(breaking, cx)) .child(SharedString::from(match breaking { true => format!("{kind}!"), false => kind.to_string(), @@ -837,7 +961,9 @@ impl Tty7App { 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. + // 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, @@ -850,8 +976,13 @@ impl Tty7App { 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. _ => ( - theme.muted.opacity(0.9), + gpui::transparent_black(), theme.muted_foreground, gpui::FontWeight::NORMAL, ), @@ -860,6 +991,11 @@ impl Tty7App { 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.)) @@ -883,6 +1019,9 @@ impl Tty7App { .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)) @@ -900,7 +1039,22 @@ impl Tty7App { } impl Tty7App { - /// The section's own title row: fold, count, gutter toggle, scope picker. + /// 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, @@ -909,17 +1063,17 @@ impl Tty7App { ) -> 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()), }); - let collapsed = self.scm.graph.lanes_collapsed; h_flex() .flex_none() .items_center() - .gap(px(6.)) - .h(px(24.)) + .gap(px(4.)) + .h(px(GRAPH_HEADER_H)) .pl(px(CONTENT_INSET)) .pr(px(crate::ui::app::tile_trailing_inset_sm())) .child( @@ -931,6 +1085,9 @@ impl Tty7App { .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, @@ -945,6 +1102,10 @@ impl Tty7App { .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)) @@ -955,43 +1116,32 @@ impl Tty7App { ) .when(expanded, |row| { row.child( - div() - .id("scm-graph-lanes") - .flex_none() - .size(px(18.)) - .flex() - .items_center() - .justify_center() - .rounded(px(4.)) - .cursor_pointer() - .when(collapsed, |d| d.bg(cx.theme().secondary)) - .hover(|s| s.bg(cx.theme().secondary)) - .child( - Icon::new(match collapsed { - true => IconName::ChevronRight, - false => IconName::ChevronLeft, - }) - .size(px(11.)) - .text_color(muted), - ) - .tooltip(move |window, cx| { - gpui_component::tooltip::Tooltip::new(match collapsed { - true => t(L10nKey::ScmGraphShowLanes), - false => t(L10nKey::ScmGraphFoldLanes), - }) - .build(window, cx) - }) - .on_click(cx.listener(|this, _, _, cx| { - this.scm.graph.lanes_collapsed = !this.scm.graph.lanes_collapsed; - cx.notify(); - })), + // 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(18.)) + .h(px(GRAPH_TILE)) .rounded(px(4.)) + .dropdown_caret(true) .label(scope_label(&self.scm.graph.scope)) .text_color(muted) .dropdown_menu_with_anchor( @@ -1294,6 +1444,12 @@ impl Tty7App { 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(); @@ -1308,7 +1464,15 @@ impl Tty7App { div() .flex_1() .min_w(px(0.)) - .child(gpui_component::input::Input::new(&input).xsmall()), + // `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| { @@ -1395,8 +1559,9 @@ mod tests { for lane in 4u16..=tty7_core::core::git::log::MAX_LANES { assert_eq!(project(lane, cap), 3, "lane {lane} escaped the last column"); } - // A single column is the folded gutter, and it has to swallow every lane - // rather than saturating into a negative index. + // `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); } @@ -1422,15 +1587,15 @@ mod tests { } #[test] - fn the_gutter_narrows_with_the_panel_and_folds_to_one() { + 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., false), 5); - assert_eq!(max_lanes(216., false), 4); - assert_eq!(max_lanes(320., false), 6); - assert_eq!(max_lanes(160., false), GRAPH_MIN_LANES); + 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, false); + 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", @@ -1440,12 +1605,71 @@ mod tests { } // 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., false), GRAPH_MIN_LANES); + 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., false), GRAPH_MAX_LANES); - assert!(max_lanes(4000., false) <= LANE_SLOTS); - assert_eq!(max_lanes(260., true), 1); + 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] @@ -1501,6 +1725,29 @@ mod tests { ); } + /// 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() { @@ -1559,22 +1806,6 @@ mod tests { })); } - #[gpui::test] - fn the_lane_gutter_folds_and_stays_folded(cx: &mut TestAppContext) { - crate::core::config::pin_test_config_dir(); - let (app, mut vcx) = harness(cx); - - // Session state rather than config: unlike the section's own fold, this - // is a reading posture for one repository's shape, not a preference. - assert!(!app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); - app.update(&mut vcx, |app, cx| { - app.scm.graph.lanes_collapsed = true; - cx.notify(); - }); - vcx.run_until_parked(); - assert!(app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); - } - #[gpui::test] fn opening_a_row_hands_that_commit_to_the_detail_view(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); @@ -1657,6 +1888,57 @@ mod tests { 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!( diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index b1c331a0..7e3ca090 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -11,7 +11,7 @@ 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, ButtonVariants as _}; +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::{ @@ -26,10 +26,11 @@ use tty7_core::core::git::status::{ use crate::terminal::git_data::status_of; use crate::terminal::git_diff::DiffSource; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +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::{git_badge, info_chip}; +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}; @@ -37,23 +38,29 @@ 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. -const ROW_INSET: f32 = 4.; - -/// The row-button tile, one step below `TILE_SIZE_SM`. /// -/// These belong next to the other tile sizes in `app.rs`; they are here -/// because that file is being rewritten elsewhere this cycle, and moving them -/// is a one-line change once it settles. -pub(crate) const TILE_SIZE_XS: f32 = 18.; -pub(crate) const TILE_GLYPH_XS: f32 = 11.; +/// 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 @@ -72,6 +79,77 @@ const BRANCH_NAME_CHARS: usize = 24; /// 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. /// @@ -204,12 +282,7 @@ impl Tty7App { self.scm_load_branches(repo, cx); let mono = cx.theme().mono_font_family.clone(); let theme = cx.theme(); - let (accent, warning, muted, fg) = ( - theme.accent, - theme.warning, - theme.muted_foreground, - theme.foreground, - ); + 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); @@ -218,6 +291,9 @@ impl Tty7App { .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())) @@ -230,7 +306,11 @@ impl Tty7App { // 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 chips. + // 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() @@ -248,7 +328,7 @@ impl Tty7App { self.scm_branch_menu(repo, status, cx), ), ) - .children(others.map(|count| info_chip(&format!("+{count}"), accent, muted, &mono))) + .children(others.map(|count| branch_note(&format!("+{count}"), muted, &mono))) .when(detached, |this| { this.child(info_chip( t(L10nKey::ScmDetached), @@ -265,12 +345,20 @@ impl Tty7App { &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), accent, muted, &mono)) + 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| info_chip(&text, accent, muted, &mono)), + .map(|text| branch_note(&text, muted, &mono)), ) .child( crate::ui::tab_strip::chrome_tile_sized( @@ -510,6 +598,8 @@ impl Tty7App { .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())) @@ -555,6 +645,11 @@ impl Tty7App { /// 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, @@ -564,7 +659,7 @@ impl Tty7App { ) -> AnyElement { let input = self.scm_commit_input(repo, status, window, cx); let focused = input.read(cx).focus_handle(cx).is_focused(window); - let theme = cx.theme(); + let sf = cx.global::().sidebar; div() .key_context(COMMIT_KEY_CONTEXT) .flex_none() @@ -572,16 +667,32 @@ impl Tty7App { .pt(px(6.)) .child( div() - // The resting height of `panel_search`, so every input - // row in the panel sits on the same line. - .min_h(px(30.)) - .max_h(px(120.)) - .rounded(crate::ui::rounding::CARD_RADIUS) - .border_1() - .border_color(if focused { theme.ring } else { theme.border }) - .bg(theme.input) - .px(px(8.)) - .py(px(6.)) + // `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() @@ -605,7 +716,7 @@ impl Tty7App { let input = cx.new(|cx| { InputState::new(window, cx) .multi_line(true) - .auto_grow(1, 6) + .auto_grow(MSG_ROWS, MSG_ROWS_MAX) .placeholder(t(L10nKey::ScmCommitPlaceholder)) }); self.scm.commit_input = Some(input.clone()); @@ -664,6 +775,21 @@ impl Tty7App { 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, @@ -672,85 +798,169 @@ impl Tty7App { ) -> 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() - .gap(px(4.)) + .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( - Button::new("scm-commit") - .primary() - .h(px(28.)) + div() .flex_1() - .label(t(plan.label)) - .disabled(!plan.enabled) - .when(!plan.enabled, |b| b.tooltip(t(L10nKey::ScmNothingToCommit))) - .on_click(cx.listener(move |this, _, window, cx| { - this.scm_commit(repo_for_button.clone(), this.scm.amend, window, cx); - })), + .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(L10nKey::ScmNothingToCommit))) + .on_click(cx.listener(move |this, _, window, cx| { + this.scm_commit( + repo_for_button.clone(), + this.scm.amend, + 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)), ) - .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; - crate::ui::tab_strip::chrome_tile_sized( - Button::new("scm-commit-menu").icon(Icon::new(IconName::ChevronDown)), - 28., - 12., - false, - cx, - ) - .rounded(crate::ui::rounding::CARD_RADIUS) - .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({ + 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| { + 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.amend = !this.scm.amend; - cx.notify(); + this.scm_stash_all(repo.clone(), window, cx) }); } - }), - ); - 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() + })) + } + }) + .into_any_element() } /// Title over a scrolling body, with the panel's own scroll handle. @@ -774,6 +984,13 @@ impl Tty7App { } /// …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, @@ -981,6 +1198,10 @@ impl Tty7App { 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)) @@ -1118,6 +1339,12 @@ impl Tty7App { } }) .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() @@ -1149,10 +1376,6 @@ impl Tty7App { } /// The buttons that appear over a hovered row. - /// - /// Absolutely positioned and opaque, so they cover 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. fn scm_row_actions( &self, row: &SharedString, @@ -1166,17 +1389,7 @@ impl Tty7App { // 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 = h_flex() - .occlude() - .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()); + let mut actions = action_strip(row, backing); for &(verb, ref icon) in row_verbs(group) { let id = SharedString::from(format!("scm-{verb:?}-{group:?}-{path}")); @@ -1203,17 +1416,7 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let paths = writable_paths(entries); - let mut actions = h_flex() - .occlude() - .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()); + let mut actions = action_strip(row, backing); for &(verb, ref icon) in group_verbs(group) { let id = SharedString::from(format!("scm-all-{verb:?}-{group:?}")); @@ -1239,8 +1442,8 @@ impl Tty7App { actions.into_any_element() } - /// An 18px 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. + /// 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, @@ -1436,6 +1639,43 @@ impl Tty7App { } } +/// 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 @@ -1532,11 +1772,17 @@ pub(crate) fn head_label(head: &HeadState) -> String { } } -/// The `↑2 ↓1` chip, or nothing. +/// 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 chip that is always there stops being read. +/// 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)>, @@ -1546,12 +1792,67 @@ pub(crate) fn tracking_chip( } match ahead_behind? { (0, 0) => None, - (ahead, 0) => Some(format!("↑{ahead}")), - (0, behind) => Some(format!("↓{behind}")), (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 @@ -1929,6 +2230,19 @@ mod tests { } } + /// 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, @@ -2032,13 +2346,15 @@ mod tests { #[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") + Some("↑2 ↓0") ); assert_eq!( tracking_chip(Some("origin/main"), Some((0, 1))).as_deref(), - Some("↓1") + Some("↑0 ↓1") ); assert_eq!( tracking_chip(Some("origin/main"), Some((2, 1))).as_deref(), @@ -2445,3 +2761,141 @@ mod render_idle_gpui_tests { 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/state.rs b/src/ui/scm/state.rs index 06f7f77f..0465b09f 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -164,10 +164,6 @@ pub(crate) struct GraphState { /// empty graph rather than the previous repository's history. pub(crate) page: Option>, pub(crate) page_key: Option<(RepoKey, u64, GraphScope)>, - /// Fold the lane gutter down to a single column. Worth about six - /// characters of the message, which at this width is the difference - /// between reading a subject and reading its first word. - pub(crate) lanes_collapsed: bool, /// 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. diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index d1bb1582..bdf101d6 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -19,7 +19,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}; #[derive(Clone, Copy)] @@ -895,12 +895,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() @@ -910,7 +919,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, ) @@ -922,7 +931,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, ) From e76d545655765235fd51143b1b5597597c1c64d1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:21:48 +0800 Subject: [PATCH 30/36] test(git): build the log tests a repository instead of reading this one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests pointed `load_page` and `local_branches` at `CARGO_MANIFEST_DIR` — the checkout the tests happen to be running inside — and then asserted things only a developer's clone is true of. `actions/checkout` clones shallow, so "five commits" found one, and a pull-request build checks out the merge ref detached, so "this checkout is on a branch" found no branch. Both assertions were about the environment; the code under test was answering correctly in each case. They now build their own history, following `status.rs`'s `scratch()`: a temporary directory removed on drop, `user.name` and `user.email` pinned because a runner has neither, and `symbolic-ref` rather than `init -b` so the branch name does not depend on the git version. One test forks a side branch and merges it back over seven commits, so lane layout is checked against a shape with more than one lane in it; the other makes a single commit that modifies, adds, deletes and renames, so the `--numstat` / `--name-status` join is exercised on four paths rather than on whatever HEAD happened to touch. Owning the history let the assertions get stricter rather than looser: the commit count, the branch list and the per-file `(added, removed)` pairs are now exact, the paging check is honest about how much history exists, and the merge row is asserted to carry two parents and two outgoing edges. The three other tests reading `CARGO_MANIFEST_DIR` were audited and left alone — one bails when `git log` fails, one falls back to a short sha on a detached HEAD, and the third is `#[ignore]`. --- crates/tty7-core/src/core/git/log.rs | 256 +++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 32 deletions(-) diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index f584b5ad..36fe3be1 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -1702,57 +1702,227 @@ mod tests { assert!(!is_rev("HEAD\nrm -rf")); } - #[test] - fn this_repo_answers_for_one_commit_and_its_files() { - let host = crate::host::local::LocalHost::new(); - let here = Path::new(env!("CARGO_MANIFEST_DIR")); - // A source tarball is a legitimate place to run the tests from. - let Some(page) = load_page(&*host, here, &GraphScope::Head, 2) else { - return; - }; - let Some(head) = page.commits.first() else { - return; - }; + // ----- 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. - let shown = load_commit(&*host, here, &head.oid).expect("HEAD is a commit"); + 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 and signing settings pinned: a CI runner has + /// no `user.name` at all and would refuse to commit, and a developer may + /// have signing turned on globally. + fn run(host: &dyn Host, cwd: &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); + 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. `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; + } + // 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, here, "-n"), None); + assert_eq!(load_commit(&*host, repo, "-n"), None); - let files = commit_files(&*host, here, &head.oid).expect("HEAD touched something"); - assert!(!files.is_empty(), "no commit in this repo is empty"); + 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:?}" ); - // The whole reason the two commands are run separately. - assert!( - files - .iter() - .any(|f| f.added.is_some() || f.removed.is_some() || f.binary), - "not one row got its counts: {files:?}" + let 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:?}" ); - let branches = local_branches(&*host, here); - assert!(!branches.is_empty(), "this checkout is on a branch"); + 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 this_repo_lays_out_one_row_per_commit() { + fn a_real_repository_lays_out_one_row_per_commit() { let host = crate::host::local::LocalHost::new(); - let here = Path::new(env!("CARGO_MANIFEST_DIR")); - // Graceful about not being in a repository at all: a source tarball is - // a legitimate place to run the tests from. - let Some(page) = load_page(&*host, here, &GraphScope::Head, 30) else { + 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!(!page.commits.is_empty(), "this repo has commits"); - assert!(page.max_lanes >= 1); + 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))); @@ -1764,8 +1934,30 @@ mod tests { ); assert_lanes_line_up(&page.rows); - let page = load_page(&*host, here, &GraphScope::HeadAndUpstream, 5).unwrap(); + // 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 commits is not the whole history"); + assert!(!page.complete, "five of the seven is not the whole history"); } } From 4d5ac5913c19d6cc4f29a25e9e95895d97c22910 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:46:56 +0800 Subject: [PATCH 31/36] fix(scm): gate the graph's idle test on unix, like its three siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_window::harness_with_pane` is `#[cfg(unix)]` — it hands back a `std::os::unix::net::UnixStream` — so a test module that calls it has to be gated the same way. `panel.rs`, `detail.rs` and `file_tree.rs` all declare theirs `#[cfg(all(test, unix))]`; this one said only `#[cfg(test)]`, which broke the Windows test build with E0425 while compiling fine everywhere a developer looks. Nothing was lost by gating it: the module holds one test, and its own doc comment already says it has to run against a real repository and a real pane. --- src/ui/scm/graph.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index f4e9e980..0f33cda2 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -1999,7 +1999,11 @@ mod tests { /// 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. -#[cfg(test)] +/// +/// `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}; From d0d5e149c7d7738fd765c4c6504f826354a2e482 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:08:46 +0800 Subject: [PATCH 32/36] fix(git): compare watch directories in one spelling of a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scm_watch_dirs` hands back what `git rev-parse` answered, and git writes forward slashes and no extended-length prefix even on Windows. The test's expected side is built from `fs::canonicalize`, which on Windows returns `\\?\C:\…` — so the two named the same directory and compared unequal, and the Windows job failed on a path the watcher would have been perfectly happy with. Both spellings reach the same directory through the Win32 file APIs, so the watcher is right to pass git's answer straight to `Host::watch` and nothing changes outside the test. The five assertions now go through a `one_spelling` helper and keep their exact-equality teeth; on unix it is a no-op, which is why this was invisible until Windows CI ran the branch for the first time. --- src/terminal/git_data.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 6f6aa884..e891e41b 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -1591,9 +1591,9 @@ mod tests { assert!(run(&*host, &repo, &["add", "-A"])); assert!(run(&*host, &repo, &["commit", "--quiet", "-m", "base"])); - let plain = scm_watch_dirs(&*host, &repo).expect("a repository was just created here"); - assert_eq!(plain[0], repo.join(".git")); - assert!(plain.contains(&repo.join(".git").join("refs").join("heads"))); + 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( @@ -1603,18 +1603,41 @@ mod tests { ) { return; // git too old for worktrees } - let dirs = scm_watch_dirs(&*host, &linked).expect("the linked worktree is a repository"); + let dirs = spelled(&scm_watch_dirs(&*host, &linked).expect("the worktree is a repository")); assert!( - dirs[0].starts_with(repo.join(".git").join("worktrees")), + 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(&repo.join(".git")), + 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(&repo.join(".git").join("refs").join("heads").join("feat")), + 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() + } } From 5c65e2b08f804c89843e9a6c7157b74410d8a7a4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:02:29 +0800 Subject: [PATCH 33/36] test(git): pin the git config the fixtures assume, and one path spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Windows-only failures, both of them the tests asserting on the runner's git rather than on the code. `core.autocrlf` is `true` by default in Git for Windows, so a file written as `one\n`, committed, and restored by `checkout --` comes back as `one\r\n`. The `-c` list the helpers already pass would not have fixed it: the checkout in that round trip is `run_op`, production code running its own git with no overrides. So the pins go into `/.git/config` right after `init`, where repository config outranks the system config that carries the default. The other is the same path-spelling mismatch fixed earlier in `git_data.rs`: `git rev-parse` answers with forward slashes and no extended-length prefix even on Windows, while `fs::canonicalize` returns `\\?\C:\…`. Both name the same directory and the Win32 APIs take either, so the production path is right and only the comparison needs one spelling. Fixed as classes rather than as instances. `core/git/mod.rs` gains a `test_support` module holding the `-c` list, the repo-config pin and the path normaliser, and `status.rs`, `log.rs`, `ops.rs` and `diff.rs` all read from it — `ops.rs` had no config pins at all and `diff.rs` was missing gpgsign. `git_data.rs` keeps its own copy because a `#[cfg(test)]` item does not exist in the `tty7-core` the binary crate links against; a comment says so and points at the other copy. The normaliser has its own test over literal `\\?\C:\…`, `C:\…`, git's `C:/…` and a unix path, and the line-ending fix was reproduced locally by pointing `GIT_CONFIG_SYSTEM` at a config with `core.autocrlf = true`: that panics exactly as CI did with the pins reverted, and passes with them. One latent hazard hardened while here — `status.rs`'s scratch directory had no pid in its name, unlike its sibling, so a leftover that resisted removal would have been silently reused as a fixture. --- crates/tty7-core/src/core/git/diff.rs | 12 ++- crates/tty7-core/src/core/git/log.rs | 23 +++--- crates/tty7-core/src/core/git/mod.rs | 98 +++++++++++++++++++++++++ crates/tty7-core/src/core/git/ops.rs | 22 ++++-- crates/tty7-core/src/core/git/status.rs | 68 +++++++++-------- src/terminal/git_data.rs | 11 +++ 6 files changed, 180 insertions(+), 54 deletions(-) diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 01761bd0..c5eb5e0c 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -750,6 +750,7 @@ fn parse_combined_header_path(rest: &str) -> 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 @@ -1379,7 +1380,9 @@ index 1..2 100644 let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).ok()?; let run = |args: &[&str]| { - git::git_output(&dir, args) + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + git::git_output(&dir, &full) .ok() .filter(|out| out.success()) .is_some() @@ -1387,8 +1390,11 @@ index 1..2 100644 if !run(&["init", "-q", "-b", "mainwork", "."]) { return None; } - run(&["config", "user.email", "t@tty7.test"]); - run(&["config", "user.name", "tty7 test"]); + // 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"]); diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index 36fe3be1..45d164e8 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -1079,6 +1079,7 @@ fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { #[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]>) { ( @@ -1728,29 +1729,25 @@ mod tests { Some(Scratch(dir)) } - /// Runs git with the identity and signing settings pinned: a CI runner has - /// no `user.name` at all and would refuse to commit, and a developer may - /// have signing turned on globally. + /// 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 = vec![ - "-c", - "user.name=tty7 test", - "-c", - "user.email=test@tty7.invalid", - "-c", - "commit.gpgsign=false", - ]; + 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. `false` means there is no git on this - /// machine, which is not a failure. + /// `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, diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index 1e377597..5d792a2f 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -340,14 +340,112 @@ fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> { 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(); diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index d758719a..910a157e 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -683,6 +683,7 @@ fn push_section(buffer: &mut String, section: &str) { #[cfg(test)] mod tests { use super::*; + use crate::core::git::test_support::{PINS, pin_repo_config}; fn born() -> HeadState { HeadState::Branch { @@ -1497,8 +1498,13 @@ mod tests { } } + /// 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 out = host.git(dir, args).expect("git runs"); + 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: {}", @@ -1524,9 +1530,11 @@ mod tests { let dir = repo.dir.clone(); run(host, &dir, &["init", "-q"]); - run(host, &dir, &["config", "user.email", "tty7@example.com"]); - run(host, &dir, &["config", "user.name", "tty7"]); - run(host, &dir, &["config", "commit.gpgsign", "false"]); + // 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 @@ -1610,6 +1618,8 @@ mod tests { ) .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", @@ -1624,9 +1634,7 @@ mod tests { let dir = repo.dir.clone(); run(host, &dir, &["init", "-q"]); - run(host, &dir, &["config", "user.email", "tty7@example.com"]); - run(host, &dir, &["config", "user.name", "tty7"]); - run(host, &dir, &["config", "commit.gpgsign", "false"]); + assert!(pin_repo_config(&dir)); repo.write("a.txt", "one\n"); run(host, &dir, &["add", "-A"]); run(host, &dir, &["commit", "-q", "-m", "initial"]); diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index 0e84d4be..b7854096 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -930,6 +930,7 @@ fn read_prefilled_message(host: &dyn Host, git_dir: &Path, listing: &[Entry]) -> #[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 @@ -1465,27 +1466,42 @@ mod tests { } fn scratch(name: &str) -> Option { - let dir = std::env::temp_dir().join(format!("tty7-scm-status-{name}")); + // 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 and signing settings pinned, so the test does - /// not depend on whatever is in the developer's `~/.gitconfig`. + /// 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 = vec![ - "-c", - "user.name=tty7 test", - "-c", - "user.email=test@tty7.invalid", - "-c", - "commit.gpgsign=false", - ]; + 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(); @@ -1494,15 +1510,9 @@ mod tests { }; let repo = &scratch.0; - if !run(&*host, repo, &["init", "--quiet"]) { + if !init_repo(&*host, repo) { return; // no git on this machine } - // 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"] - )); 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"])); @@ -1530,7 +1540,13 @@ mod tests { assert_eq!(status.stash_count, 0); assert!(!status.truncated); assert!(!status.is_clean()); - assert_eq!(status.root, std::fs::canonicalize(repo).unwrap()); + // `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> { @@ -1575,14 +1591,9 @@ mod tests { }; let repo = &scratch.0; - if !run(&*host, repo, &["init", "--quiet"]) { + if !init_repo(&*host, repo) { return; } - assert!(run( - &*host, - repo, - &["symbolic-ref", "HEAD", "refs/heads/main"] - )); std::fs::write(repo.join("c.txt"), "base\n").unwrap(); assert!(run(&*host, repo, &["add", "-A"])); assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); @@ -1629,12 +1640,7 @@ mod tests { ) { return; } - assert!(run(&*host, &repo, &["init", "--quiet"])); - assert!(run( - &*host, - &repo, - &["symbolic-ref", "HEAD", "refs/heads/main"] - )); + 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"])); diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index e891e41b..328743e2 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -1557,6 +1557,13 @@ mod tests { // -- 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", @@ -1565,6 +1572,10 @@ mod tests { "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) From a764d92132225710b6e5a33825fa17db6a7d1135 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:24:09 +0800 Subject: [PATCH 34/36] fix(scm): sequence compound verbs, cap graph paging, back off failed loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on this branch, all in the seams between async operations: - Commit-and-push, commit-and-sync, sync and discard-all dispatched both halves into the worker pool at once, so a push could resolve the branch tip before the commit (or pull) it was waiting for and quietly send the old one. Compound verbs now carry a ScmFollowUp that the first half's landing closure starts on success only; a refused commit, a failed pull or a cancelled confirmation drops the follow-up with it. - Push sent `git push ` with the branch taken from the upstream's name — a bare name means a *local* branch, so `feat` tracking `origin/main` pushed stale local `main`. The refspec is now `HEAD:`, and the branch is validated with the full branch check since a `:` would smuggle a second refspec in. - `scm.committing` was armed before the amend confirmation and never disarmed on failure, so a cancelled prompt (or a hook rejection) plus any later unrelated HEAD move cleared a message that was never committed. It is armed at dispatch and disarmed when the commit errors. - Discard-all fed staged-only paths to `checkout --`, where a staged deletion sank the whole batch as an unmatched pathspec. Only unstaged paths go in, one confirmation covers both halves, and the two gits no longer run concurrently. - One "load more" click at 5000 commits grew `requested` past what `load_page` clamps to, so the freshness check never passed again and every frame refetched the full page. Growth stops at the cap, the button hides there, and a failing `git log` is remembered per key instead of being retried from every render. - A repository switch now drops the previous repository's page before anything can draw it or grow from it — a stale row's context menu used to build ops for the new repo with the old repo's rev. - A watch that failed to open was retried at frame rate, one host round trip per render; it now rests for WATCH_RETRY between attempts. - Non-network writes on a remote host ran under the interactive 20-second deadline while the server ran the job to completion, so a slow pre-commit hook was reported failed and then landed anyway. Every write now goes through git_with_deadline, 120s for local verbs. --- crates/tty7-core/src/core/git/ops.rs | 71 +++++- src/terminal/git_data.rs | 98 ++++++-- src/ui/scm/actions.rs | 324 ++++++++++++++++++++++----- src/ui/scm/graph.rs | 122 +++++++++- src/ui/scm/panel.rs | 1 + src/ui/scm/state.rs | 6 + 6 files changed, 533 insertions(+), 89 deletions(-) diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index 910a157e..26af6a7a 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -23,6 +23,14 @@ pub const MAX_PATHSPECS_PER_CALL: usize = 200; /// — 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 { @@ -114,6 +122,11 @@ pub enum GitOp { }, 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, @@ -384,7 +397,9 @@ impl GitOp { out.push("--force-with-lease".into()); } out.push(remote.clone()); - out.push(branch.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] } } @@ -422,7 +437,10 @@ impl GitOp { | GitOp::Reset { rev, .. } => self.check_rev("revision", rev)?, GitOp::Push { remote, branch, .. } => { self.check_rev("remote", remote)?; - self.check_rev("branch", branch)?; + // 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), @@ -619,15 +637,19 @@ pub fn run_op( .collect::>() }; - // A push over a slow link outlives the deadline a `Git` request gets - // by default, which is sized for interactive queries. The no-prompt - // environment is not this layer's business: `LocalHost` puts it on - // every call, on both sides of the wire. - let spawned = if op.is_network() { - host.git_with_deadline(root, &borrowed, GIT_NETWORK_DEADLINE) + // 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 { - host.git(root, &borrowed) + GIT_WRITE_DEADLINE }; + let spawned = host.git_with_deadline(root, &borrowed, deadline); let out = spawned.map_err(|err| GitOpError { op: label, kind: GitOpErrorKind::Spawn, @@ -1160,7 +1182,10 @@ mod tests { force_with_lease: false, } .commands(&born()), - vec![vec!["push", "origin", "main"]], + // `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 { @@ -1170,7 +1195,13 @@ mod tests { force_with_lease: true, } .commands(&born()), - vec![vec!["push", "-u", "--force-with-lease", "origin", "main"]], + vec![vec![ + "push", + "-u", + "--force-with-lease", + "origin", + "HEAD:main" + ]], ); } @@ -1262,6 +1293,24 @@ mod tests { .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] diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 328743e2..e9d1ad59 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -32,14 +32,27 @@ use crate::core::git::status::{StatusIndex, 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 may have in flight. +/// 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. +/// 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 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); @@ -301,6 +314,10 @@ struct RepoWatch { /// 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, } @@ -446,13 +463,18 @@ impl ScmData { self.subs.is_empty() && self.watches.is_empty() } - /// Repositories that have a holder but no watch, and nothing on the way. - fn unwatched(&self) -> Vec<(HostId, PathBuf)> { + /// 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, + Some(watch) => { + watch.sub.is_none() + && !watch.opening + && watch.retry_at.is_none_or(|at| at <= now) + } None => true, }) .collect() @@ -472,9 +494,18 @@ impl ScmData { true } - fn finish_watch_open(&mut self, host: HostId, root: &Path, sub: Option>) { + 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; } @@ -713,7 +744,7 @@ impl Tty7App { } } - for (host, root) in cx.default_global::().unwatched() { + for (host, root) in cx.default_global::().unwatched(Instant::now()) { let Some(shared) = crate::ui::host_registry::HostRegistry::get(cx, host) else { continue; }; @@ -847,11 +878,11 @@ impl Tty7App { // Letting go while the watch was opening leaves the only `Arc` here, // so returning closes it. if data.generation(host, &root) != sub_gen || !data.is_subscribed(host, &root) { - data.finish_watch_open(host, &root, None); + 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); + data.finish_watch_open(host, &root, sub, Instant::now()); let Some(events) = events else { return; }; @@ -882,6 +913,7 @@ impl Tty7App { host: SharedHost, root: PathBuf, op: GitOp, + then: Option, window: &Window, cx: &mut Context, ) { @@ -903,6 +935,22 @@ impl Tty7App { 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, @@ -918,7 +966,18 @@ impl Tty7App { // 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); + } + } }, ); } @@ -1355,21 +1414,30 @@ mod tests { #[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(), vec![(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().is_empty()); + assert!(data.unwatched(now).is_empty()); - // A watch that failed to open leaves nothing behind, so the next - // frame is free to try again. - data.finish_watch_open(HostId::LOCAL, &root(), None); - assert_eq!(data.unwatched(), vec![(HostId::LOCAL, root())]); + // 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] diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index e4b23978..a91441f0 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -25,7 +25,11 @@ 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. + /// 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, @@ -40,6 +44,25 @@ pub(crate) enum ScmIntent { 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) { @@ -76,12 +99,25 @@ impl Tty7App { 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, window, cx); + self.run_git_op(host, repo.root, op, then, window, cx); return; }; let answer = window.prompt( @@ -94,7 +130,7 @@ impl Tty7App { 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, window, cx) + app.run_git_op(host, repo.root, op, then, window, cx) }); }) .detach(); @@ -119,18 +155,16 @@ impl Tty7App { ScmIntent::DiscardAll => self.scm_discard_all(repo, window, cx), ScmIntent::Commit => { let amend = self.scm.amend; - self.scm_commit(repo, amend, window, cx); + self.scm_commit(repo, amend, None, window, cx); } - ScmIntent::CommitAmend => self.scm_commit(repo, true, window, cx), + ScmIntent::CommitAmend => self.scm_commit(repo, true, None, window, cx), ScmIntent::CommitAndPush => { let amend = self.scm.amend; - self.scm_commit(repo.clone(), amend, window, cx); - self.scm_push(repo, false, window, cx); + self.scm_commit(repo, amend, Some(ScmFollowUp::Push), window, cx); } ScmIntent::CommitAndSync => { let amend = self.scm.amend; - self.scm_commit(repo.clone(), amend, window, cx); - self.scm_sync(repo, window, cx); + 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), @@ -167,6 +201,7 @@ impl Tty7App { &mut self, repo: RepoKey, amend: bool, + then: Option, window: &mut Window, cx: &mut Context, ) { @@ -181,14 +216,16 @@ impl Tty7App { 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); - // Remembered so the box can be cleared once HEAD actually moves — - // see `scm_commit_landed`. - self.scm.committing = Some((repo.clone(), status.head.clone(), message.clone())); self.scm.amend = false; - self.scm_op( + // `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, @@ -197,6 +234,7 @@ impl Tty7App { no_verify: false, all, }, + then, window, cx, ); @@ -236,50 +274,61 @@ impl Tty7App { ); } - /// Throw away everything: tracked edits and untracked files alike. + /// 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. + /// 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 tracked: Vec<_> = status - .unstaged() - .chain(status.staged()) - .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(); - if !tracked.is_empty() { - self.scm_op( - repo.clone(), - GitOp::DiscardWorktree { paths: tracked }, - window, - cx, - ); - } - if !untracked.is_empty() { - let directories = untracked.iter().any(|p| p.as_str().ends_with('/')); - self.scm_op( - repo, - GitOp::DiscardUntracked { - paths: untracked, - directories, - }, - window, - cx, - ); - } + 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. - fn scm_push( + pub(crate) fn scm_push( &mut self, repo: RepoKey, force_with_lease: bool, @@ -312,24 +361,97 @@ impl Tty7App { ); } - /// Pull then push, which is what "sync" means everywhere else. + /// 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. - fn scm_sync(&mut self, repo: RepoKey, window: &mut Window, cx: &mut Context) { + 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( - repo.clone(), + self.scm_op_then( + repo, GitOp::Pull { mode: PullMode::FfOnly, }, + Some(ScmFollowUp::Push), window, cx, ); + } else { + self.scm_push(repo, false, window, cx); } - 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")`. @@ -364,6 +486,104 @@ fn confirm_verb(loss: Destructive) -> &'static str { #[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() { diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 0f33cda2..b12c3e37 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -54,7 +54,8 @@ use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, Po 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, RefDeco, RefKind, + Commit, CommitPage, Edge, GRAPH_PAGE, GraphRow, GraphScope, Lane, MAX_GRAPH_COMMITS, RefDeco, + RefKind, }; use tty7_core::core::git::ops::{GitOp, ResetMode}; @@ -559,6 +560,23 @@ impl Tty7App { /// 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. @@ -566,7 +584,14 @@ impl Tty7App { .try_global::() .map_or(0, |data| data.epoch(repo.host, &repo.root)); let scope = self.graph_scope(); - let want = self.scm.graph.requested.max(GRAPH_PAGE); + // 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 @@ -575,7 +600,10 @@ impl Tty7App { .page .as_ref() .is_some_and(|p| p.requested >= want); - if fresh || self.scm.graph.loading { + // 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 { @@ -590,11 +618,13 @@ impl Tty7App { move |h| tty7_core::core::git::log::load_page(h, &root, &query, want), move |this, page, cx| { this.scm.graph.loading = false; - // A page that came back for a scope nobody is looking at any - // more is dropped rather than shown for one frame. - if let Some(page) = page { - this.scm.graph.page = Some(Arc::new(page)); - this.scm.graph.page_key = Some(key); + 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(); }, @@ -686,7 +716,9 @@ impl Tty7App { .filter(|i| matches_query(&page.commits[*i], q)) .collect(), }; - let more = query.is_none() && !page.complete; + // 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); // With the gutter gone the text takes the panel's own inset, so a @@ -873,6 +905,19 @@ impl Tty7App { } } +/// 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 @@ -1030,8 +1075,7 @@ impl Tty7App { false => t(L10nKey::ScmGraphLoadMore), })) .on_click(cx.listener(|this, _, _, cx| { - let now = this.scm.graph.requested.max(GRAPH_PAGE); - this.scm.graph.requested = now.saturating_add(GRAPH_PAGE); + this.scm.graph.requested = next_page_request(this.scm.graph.requested); cx.notify(); })) .into_any_element() @@ -1871,6 +1915,62 @@ mod tests { 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"); diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 7e3ca090..e7be760f 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -874,6 +874,7 @@ impl Tty7App { this.scm_commit( repo_for_button.clone(), this.scm.amend, + None, window, cx, ); diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 0465b09f..a4b1f8b3 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -164,6 +164,12 @@ pub(crate) struct GraphState { /// 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. From 58d7ef5838304580972a8cf51abb531cfc486dca Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:31:58 +0800 Subject: [PATCH 35/36] fix(scm): close out the review's minor findings across the data and UI layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second pass over the branch review: every remaining finding verified against the code, the real ones fixed. Data layer: - A truncated log parse is never called complete: RecordSplitter drops an overlong record whole and reports the count (delivered cut short, a commit body cut mid-way reads as the real message), parse_log carries a truncated flag past MAX_LOG_BYTES, and load_page only says "end of history" when the parse read everything git returned. - Every scope pins symbolic revs to shas before walking, so a commit landing between two pages can no longer shift where page two starts under Head and Refs scopes; unresolvable names read as "no history" rather than as a load failure. --parents was doing nothing and is gone; edge sort is stable so a merge's Outs keep first-parent order. - The lane model's central invariant now names the join case — a merge whose second parent already has a lane reserved sends its Out onto that lane, one line below the cut, not two — with a golden test for the commonest merge topology of all, which no golden covered. - DiffSource revs get the same could-be-an-option guard log already had; C-quoted paths decode the full escape set (a tab decoded to a literal t broke the :(literal) re-probe); rename from/to lines override the ambiguous diff --git header; combined-diff line numbers follow the sides rather than the colour, so a " +" line no longer drifts every number below it. - A rename's old path stays out of the per-file decoration map, where it outranked a file re-created at that path; ignored records decorate as Ignored, not Modified; checkout gains the trailing -- that keeps a stale name from falling back to a worktree-clobbering path checkout; unstage before the first commit takes -f (worktree- safe with --cached); batches split by bytes as well as count for Windows' 32K command line; a deadline expiry reports Timeout, not "git could not be run"; error details keep both streams. - probe_status distinguishes "not a repository" from "could not ask": a dropped link keeps the cached status (stale beats blank) and rests 10s instead of erasing the panel, while a definitive not-a-repo also drops the cwd→root mappings so the panel stops drawing Loading for a repository that is gone. Probe and watch work are wrapped against panics that would wedge their in-flight bookkeeping forever, watch landings check the wipe counter, superseded probes relaunch through the debounce, and a refused network slot says so instead of eating the click. UI: - Reset --hard confirms with its own words (commits fall off the branch), not the discard dialog's; a merge commit whose prefilled message the user cleared is committable again; the disabled commit button distinguishes "nothing to commit" from "write a message". - Selection highlight matches on the diff source too, so a file staged and edited again no longer lights both of its rows for one overlay. - The graph materializes only the rows in the viewport window (5000 flex children per frame was most of a frame), row clicks carry the page Arc and an index instead of a deep Commit clone per row per frame, filter results are cached per (page, query), and a selected merge ring's hole matches the selection band under it. - A failed commit_files read says the list could not be read instead of "0 files changed"; the STAGED chip and the graph's relative times go through the i18n table; the keys-awaiting-a-caller list is pruned to the seven that still are; the orphaned PanelUntracked key is gone; the zh commit placeholder reads naturally. 2398 tests, 0 failures. Known flake: daemon::singleton's second-claim test, untouched by this branch, fails ~1 in 3 full parallel runs and passes alone. --- crates/tty7-core/src/core/git/diff.rs | 252 +++++++++++++++++++---- crates/tty7-core/src/core/git/log.rs | 208 +++++++++++++++---- crates/tty7-core/src/core/git/mod.rs | 126 ++++++++++-- crates/tty7-core/src/core/git/ops.rs | 137 +++++++++--- crates/tty7-core/src/core/git/status.rs | 163 ++++++++++++--- crates/tty7-core/src/host/conformance.rs | 42 ++-- crates/tty7-core/src/host/local.rs | 5 +- src/terminal/git_data.rs | 149 +++++++++++--- src/ui/diff_overlay.rs | 22 +- src/ui/host_ops.rs | 8 + src/ui/i18n/en.rs | 19 +- src/ui/i18n/ja.rs | 20 +- src/ui/i18n/mod.rs | 80 ++----- src/ui/i18n/zh.rs | 21 +- src/ui/scm/actions.rs | 13 +- src/ui/scm/detail.rs | 16 +- src/ui/scm/graph.rs | 96 +++++++-- src/ui/scm/panel.rs | 34 ++- src/ui/scm/path.rs | 33 ++- src/ui/scm/state.rs | 35 ++++ 20 files changed, 1168 insertions(+), 311 deletions(-) diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index c5eb5e0c..953c7415 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -163,6 +163,23 @@ impl DiffSource { 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 { @@ -358,6 +375,9 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { } 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)?; @@ -483,12 +503,25 @@ impl DiffParser { file.status = FileStatus::Deleted; return; } - if line.starts_with("rename from ") { + // 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 line.starts_with("copy from ") { + 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 ") { @@ -542,7 +575,7 @@ impl DiffParser { if !self.in_hunk { return; } - let Some((kind, text)) = split_body_line(line, self.markers) else { + let Some((kind, sides, text)) = split_body_line(line, self.markers) else { return; }; match kind { @@ -565,24 +598,19 @@ impl DiffParser { 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)) - } - }; + // 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, @@ -648,10 +676,19 @@ fn object_type(mode: &str) -> &str { &mode[..mode.len().min(3)] } -/// Splits a hunk body line into its kind 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 what happened to the line. -fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, &str)> { +/// 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 @@ -662,7 +699,17 @@ fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, &str)> { } else { return None; }; - Some((kind, &line[markers..])) + 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 { @@ -696,10 +743,15 @@ fn parse_quoted_pair(s: &str) -> Vec { continue; } match ch { - '\\' if in_quote => escaped = true, + // 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(std::mem::take(&mut cur)); + parts.push(c_unescape(&std::mem::take(&mut cur))); } in_quote = !in_quote; } @@ -710,6 +762,67 @@ fn parse_quoted_pair(s: &str) -> Vec { 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/")) @@ -922,9 +1035,9 @@ Binary files a/img.png and b/img.png differ #[test] fn quote_path_is_off_on_every_source() { - // Left on, a non-ASCII path arrives as C octal escapes that nothing - // downstream decodes — `parse_quoted_pair` would hand back the literal - // digits. Verified against git 2.50.1: `diff --git + // 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 [ @@ -941,21 +1054,74 @@ Binary files a/img.png and b/img.png differ } } + /// `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 octal_escaped_paths_are_what_the_flag_prevents() { + 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_ne!( - escaped[0].path, "中文名.txt", - "the escapes are not decoded here, which is why they must not be produced" - ); + 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"); } + /// 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()]; @@ -1168,6 +1334,20 @@ index af70335,f794161..0000000 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] diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index 45d164e8..c34840c9 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -47,6 +47,14 @@ pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024; /// 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; @@ -326,7 +334,9 @@ impl LaneAlloc { // No parents: a root. `slots[node]` was released above and nothing // claimed it, so the lane simply ends here. - edges.sort_unstable_by_key(Edge::paint_rank); + // 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 @@ -394,17 +404,28 @@ 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]) -> Vec { +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) { @@ -413,8 +434,11 @@ pub fn parse_log(stdout: &[u8]) -> Vec { }; let mut split = RecordSplitter::new(REC_SEP); split.push(stdout, &mut on_record); - split.finish(&mut on_record); - commits + let dropped = split.finish(&mut on_record); + ParsedLog { + commits, + truncated: clipped || dropped > 0, + } } fn parse_record(record: &[u8]) -> Option { @@ -646,7 +670,7 @@ pub fn load_commit(host: &dyn Host, root: &Path, rev: &str) -> Option { if !out.success() { return None; } - parse_log(&out.stdout).into_iter().next() + parse_log(&out.stdout).commits.into_iter().next() } /// One path a commit touched, with the line counts beside it. @@ -723,7 +747,10 @@ fn records(stdout: &[u8]) -> Vec { let mut on_record = |record: &[u8]| out.push(String::from_utf8_lossy(record).into_owned()); let mut split = RecordSplitter::new(0); split.push(stdout, &mut on_record); - split.finish(&mut on_record); + // 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 } @@ -886,7 +913,6 @@ pub fn load_page( // 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", - "--parents", "--decorate=full", "--no-color", LOG_PRETTY, @@ -905,8 +931,13 @@ pub fn load_page( if !out.success() { return None; } - let mut commits = parse_log(&out.stdout); - let complete = commits.len() < count; + 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() @@ -950,27 +981,24 @@ pub fn load_page( /// The revs to walk for a scope. /// -/// `HeadAndUpstream` resolves to shas first. Paging re-runs the walk with a -/// larger `-n`, and a symbolic `HEAD` 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. +/// 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 => vec!["HEAD".to_string()], + GraphScope::Head => rev(host, root, "HEAD^{commit}").into_iter().collect(), GraphScope::All => vec!["--all".to_string()], - GraphScope::Refs(refs) => { - let mut revs: Vec = 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('-')) - .cloned() - .collect(); - if revs.is_empty() { - revs.push("HEAD".to_string()); - } - revs - } + 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}") { @@ -1127,7 +1155,14 @@ mod tests { lanes } - /// Lanes crossing the row's bottom edge, sorted. + /// 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 @@ -1139,20 +1174,45 @@ mod tests { }) .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 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. + /// 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() { - for edges in [top(row), bottom(row)] { - let mut once = edges.clone(); - once.dedup(); - assert_eq!(once, edges, "row {i} has two lines on one lane: {row:?}"); - } + 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!( @@ -1330,6 +1390,37 @@ mod tests { 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(); @@ -1374,6 +1465,35 @@ mod tests { ]) } + /// 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 = [ @@ -1387,7 +1507,7 @@ mod tests { one(SHA_B, "", "", "second", ""), ] .join("\n"); - let commits = parse_log(stream.as_bytes()); + let commits = parse_log(stream.as_bytes()).commits; assert_eq!(commits.len(), 2); assert_eq!(commits[0].summary, "first"); @@ -1402,7 +1522,7 @@ mod tests { #[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()); + let commits = parse_log(stream.as_bytes()).commits; assert_eq!(commits[0].parents.as_slice(), [SHA_B, SHA_C]); assert!(commits[0].is_merge()); @@ -1413,7 +1533,7 @@ mod tests { 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()).remove(0).refs; + let refs = parse_log(stream.as_bytes()).commits.remove(0).refs; assert_eq!(refs.len(), 3); assert_eq!(refs[0].kind, RefKind::LocalBranch); @@ -1427,7 +1547,7 @@ mod tests { assert_eq!(refs[2].short, "v1.0"); let detached = one(SHA_A, "", "HEAD, refs/tags/v2", "subject", ""); - let refs = parse_log(detached.as_bytes()).remove(0).refs; + let refs = parse_log(detached.as_bytes()).commits.remove(0).refs; assert_eq!(refs[0].kind, RefKind::Head); assert!(refs[0].is_head); } @@ -1436,7 +1556,7 @@ mod tests { 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()); + let commits = parse_log(stream.as_bytes()).commits; assert_eq!( commits[0].summary, "subject", @@ -1453,7 +1573,7 @@ mod tests { record(&[SHA_B, "only two fields"]), ] .join("\n"); - let commits = parse_log(stream.as_bytes()); + let commits = parse_log(stream.as_bytes()).commits; assert_eq!(commits.len(), 1, "{commits:?}"); assert_eq!(commits[0].summary, "real"); @@ -1517,7 +1637,7 @@ mod tests { let subject = "提".repeat(400); let body = "交".repeat(4000); let stream = one(SHA_A, "", "", &subject, &body); - let commit = parse_log(stream.as_bytes()).remove(0); + let commit = parse_log(stream.as_bytes()).commits.remove(0); assert_eq!( commit.summary.len(), @@ -1573,7 +1693,7 @@ mod tests { 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()); + let logged = parse_log(one(SHA_A, "", "refs/heads/main", "s", "").as_bytes()).commits; assert_eq!(logged[0].refs[0].upstream, None); } diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index 5d792a2f..73536ea1 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -265,6 +265,9 @@ impl LineSplitter { 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, } @@ -275,6 +278,7 @@ impl RecordSplitter { RecordSplitter { sep, tail: Vec::new(), + discarding: false, dropped: 0, } } @@ -283,40 +287,63 @@ impl RecordSplitter { let mut rest = chunk; while let Some(at) = rest.iter().position(|b| *b == self.sep) { let (record, after) = rest.split_at(at); - if self.tail.is_empty() && self.dropped == 0 && record.len() <= MAX_RECORD { - on_record(record); + // 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); - let joined = std::mem::take(&mut self.tail); - self.dropped = 0; - on_record(&joined); + 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. - pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) { - if !self.tail.is_empty() { + /// 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); } - } - - /// How many bytes were discarded for overrunning [`MAX_RECORD`]. Non-zero - /// means the parse is incomplete and the caller should say so. - pub fn dropped(&self) -> usize { self.dropped } fn keep(&mut self, bytes: &[u8]) { - let room = MAX_RECORD.saturating_sub(self.tail.len()); - let take = room.min(bytes.len()); - self.tail.extend_from_slice(&bytes[..take]); - self.dropped += bytes.len() - take; + 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); } } @@ -476,6 +503,67 @@ mod tests { 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(); diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs index 26af6a7a..fe883838 100644 --- a/crates/tty7-core/src/core/git/ops.rs +++ b/crates/tty7-core/src/core/git/ops.rs @@ -19,6 +19,13 @@ use crate::host::Host; /// 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); @@ -202,10 +209,13 @@ impl GitOp { 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::LosesWorktreeEdits, + } => Destructive::LosesCommits, GitOp::Commit { amend: true, .. } => Destructive::RewritesHistory, GitOp::Push { force_with_lease: true, @@ -243,8 +253,12 @@ impl GitOp { /// 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 a dev box on CentOS 7 (git 1.8) is a real thing — - /// a version fork here would have to be tested twice forever. + /// 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 @@ -296,7 +310,12 @@ impl GitOp { } vec![out] } - GitOp::CheckoutBranch { name } => vec![argv(&["checkout", name])], + // 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, @@ -511,25 +530,46 @@ fn unstage_prefix(head: &HeadState) -> &'static [&'static str] { &["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. - &["rm", "--cached", "-r", "-q"] + // 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`. +/// `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> { - specs - .chunks(MAX_PATHSPECS_PER_CALL) - .map(|chunk| { - let mut out = argv(prefix); - out.push("--".into()); - out.extend(chunk.iter().cloned()); - out - }) - .collect() + 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. @@ -652,7 +692,13 @@ pub fn run_op( let spawned = host.git_with_deadline(root, &borrowed, deadline); let out = spawned.map_err(|err| GitOpError { op: label, - kind: GitOpErrorKind::Spawn, + // 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(), @@ -679,7 +725,13 @@ pub fn run_op( op: label, kind, message, - detail: if stderr.is_empty() { stdout } else { stderr }, + // 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(), }); @@ -865,11 +917,21 @@ mod tests { paths: vec![p("x")], } .commands(&unborn()), - vec![vec!["rm", "--cached", "-r", "-q", "--", ":(literal)x"]], + // `-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", "--", "."]], + vec![vec!["rm", "--cached", "-r", "-q", "-f", "--", "."]], ); } @@ -918,6 +980,28 @@ mod tests { 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 [ @@ -1029,7 +1113,9 @@ mod tests { name: "feature".into(), } .commands(&born()), - vec![vec!["checkout", "feature"]], + // The trailing `--` keeps a stale branch name from falling back + // to a worktree-clobbering *path* checkout. + vec![vec!["checkout", "feature", "--"]], ); assert_eq!( GitOp::CheckoutDetached { @@ -1210,10 +1296,13 @@ mod tests { 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" && batch[0] != "clean"), + !batch.iter().any(|a| a == "--force" || a == "-f" && !exempt), "{:?} would force: {batch:?}", op.label(), ); diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index b7854096..00999163 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -2,7 +2,8 @@ //! control panel, the file tree's decorations, and every button that is only //! enabled for some file states. //! -//! One `git status --porcelain=v2 --branch -z` answers all of it. That format +//! 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 @@ -222,6 +223,12 @@ impl StatusEntry { 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 { @@ -407,11 +414,13 @@ impl StatusIndex { for entry in &status.entries { let deco = entry.deco(); index.insert(entry.path.as_str(), deco); - // A rename's old path is no longer on disk, so no tree row will ask - // for it — but the directory it left did lose a file, and the - // rollup is the only place that can say so. + // 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.insert(orig.as_str(), deco); + index.rollup(orig.as_str(), deco); } } if index.files.len() > MAX_DECORATED_FILES { @@ -439,6 +448,12 @@ impl StatusIndex { .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 @@ -531,8 +546,12 @@ 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)); - split.finish(|record| parser.record(record)); - parser.finish() + 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)] @@ -793,15 +812,28 @@ fn head_state(oid: Option, head_name: Option) -> HeadState { } } -/// The whole working tree state for the repository containing `cwd`, or `None` -/// if there is no repository there. +/// 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) -> Option { - let paths = super::git( - host, +pub fn probe_status(host: &dyn Host, cwd: &Path) -> StatusProbe { + let Ok(out) = host.git( cwd, &[ "rev-parse", @@ -810,16 +842,36 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option { "--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 root = PathBuf::from(lines.next()?); + 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 git_dir = PathBuf::from(git_dir?); + let Some(git_dir) = git_dir.map(PathBuf::from) else { + return StatusProbe::Unreachable; + }; - let out = host.git(cwd, STATUS_ARGS).ok()?; + let Ok(out) = host.git(cwd, STATUS_ARGS) else { + return StatusProbe::Unreachable; + }; if !out.success() { - return None; + return StatusProbe::Unreachable; } let mut parsed = parse_porcelain_v2(&out.stdout); if parsed.ahead_behind.is_none() @@ -832,7 +884,12 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option { let operation = detect_operation(host, &git_dir, &listing); let prefilled_message = operation.and_then(|_| read_prefilled_message(host, &git_dir, &listing)); - Some(parsed.into_status(root, home, operation, prefilled_message)) + StatusProbe::Status(Box::new(parsed.into_status( + root, + home, + operation, + prefilled_message, + ))) } /// Ask for ahead/behind again when the header could not say. @@ -1436,6 +1493,48 @@ mod tests { 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] @@ -1465,6 +1564,14 @@ mod tests { } } + /// 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 @@ -1524,7 +1631,10 @@ mod tests { assert!(run(&*host, repo, &["mv", "moved.txt", "renamed.txt"])); std::fs::write(repo.join("untracked.txt"), "loose\n").unwrap(); - let status = probe_status(&*host, repo).expect("a repository was just created here"); + let status = probed( + probe_status(&*host, repo), + "a repository was just created here", + ); match &status.head { HeadState::Branch { name, oid } => { @@ -1606,7 +1716,7 @@ mod tests { // Expected to fail — that is the point. run(&*host, repo, &["merge", "other"]); - let status = probe_status(&*host, repo).expect("still a repository mid-merge"); + let status = probed(probe_status(&*host, repo), "still a repository mid-merge"); assert_eq!(status.operation, Some(RepoOperation::Merge)); assert!( status @@ -1657,7 +1767,7 @@ mod tests { std::fs::write(repo.join("f.txt"), "two\n").unwrap(); assert!(run(&*host, &repo, &["commit", "--quiet", "-am", "two"])); - let status = probe_status(&*host, &repo).expect("a repository with a remote"); + 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!( @@ -1668,13 +1778,20 @@ mod tests { 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), None); - assert_eq!(probe_status(&*host, Path::new("/no/such/tty7/path")), None); + 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 cc76fe8e..0328ae66 100644 --- a/crates/tty7-core/src/host/conformance.rs +++ b/crates/tty7-core/src/host/conformance.rs @@ -669,24 +669,32 @@ pub fn git_terminal_prompt_is_disabled(h: &dyn Host, sb: &dyn Sandbox) { mkdir(h, &repo); let Some(()) = git_repo(h, &repo) else { return }; - let configured = h.git( - &repo, - &[ - "config", - "alias.tty7prompt", - "!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]", - ], + // 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() ); - let Ok(out) = configured else { return }; - if !out.success() { - return; - } - let Ok(out) = h.git(&repo, &["tty7prompt"]) else { - return; - }; - if !out.success() { - return; - } // 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 diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs index 3564342e..be391fa7 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -50,7 +50,10 @@ const NO_PROMPT_ENV: &[(&str, Option<&str>)] = &[ /// 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(); - if std::env::var_os("GIT_SSH_COMMAND").is_none() { + // `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 diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index e9d1ad59..3b7e8793 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -12,13 +12,6 @@ //! entries a `git add` touched is a losing game; bumping a counter for the //! repository and letting readers notice they are behind is not. -// The watcher and the subscription gate now use this module, but the panel and -// the file tree — the things that read the status and run the writes — are -// still landing alongside it, so `status_of`, `index_of`, `run_git_op`, -// `shell_quote` and the `FileTree`/`Editor` subscribers have no callers yet. -// Take the allow off with the last of them; anything still unused then is. -#![allow(dead_code)] - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -28,7 +21,7 @@ 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, WorkingTreeStatus, probe_status}; +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}; @@ -44,6 +37,12 @@ use crate::ui::host_ops::{ByHost, Host, HostId, HostOps, InFlight, SharedHost, W /// (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 @@ -307,6 +306,14 @@ impl GitSubscriptions { } } +/// 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 { @@ -331,6 +338,11 @@ pub struct ScmData { 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 @@ -380,19 +392,28 @@ impl ScmData { } /// Whether what we hold was read before the last thing that changed it. - /// A repository we have never probed counts as stale. + /// 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 { - match self.read_at.get(host, root) { + 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())); } @@ -403,9 +424,14 @@ impl ScmData { 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; } @@ -571,14 +597,24 @@ impl Tty7App { let probe_root = root.clone(); let this = cx.weak_entity(); - let again = host.clone(); HostOps::run_detached( host, cx, move |h| { - let status = probe_status(h, &probe_root)?; - let index = StatusIndex::build(&status); - Some((Arc::new(status), Arc::new(index))) + // `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::(); @@ -591,8 +627,13 @@ impl Tty7App { 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 { - Some((status, index)) => { + ProbeLanding::Status(status, index) => { let same = data .status .get(id, root.as_path()) @@ -606,13 +647,27 @@ impl Tty7App { // 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. - None => { + 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 + } }; - data.read_at.insert(id, root.clone(), at); + 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. @@ -627,7 +682,13 @@ impl Tty7App { cx.refresh_windows(); } if superseded { - let _ = this.update(cx, |app, cx| app.scm_refresh(again, root, cx)); + // 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)); } }, ); @@ -832,6 +893,9 @@ impl Tty7App { 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); } } @@ -846,22 +910,29 @@ impl Tty7App { 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| { - 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 + // `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, sub, cx) + app.scm_watch_opened(id, root, sub_gen, wipe, sub, cx) }, ); } @@ -871,13 +942,20 @@ impl Tty7App { 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. - if data.generation(host, &root) != sub_gen || !data.is_subscribed(host, &root) { + // 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; } @@ -914,7 +992,7 @@ impl Tty7App { root: PathBuf, op: GitOp, then: Option, - window: &Window, + window: &mut Window, cx: &mut Context, ) { let Some(status) = status_of(cx, host.id(), &root) else { @@ -929,7 +1007,16 @@ impl Tty7App { let slot = if op.is_network() { match cx.default_global::().take_network_slot(id, &root) { Some(slot) => Some(slot), - None => return, + 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 diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 8a15eb98..5caf1658 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -146,11 +146,6 @@ impl Tty7App { } None => {} } - let seed = DiffLoad::Loading; - let epoch = match &seed { - DiffLoad::Ready(snap) => Some(scm_epoch(cx, host, &snap.root)), - _ => None, - }; self.remember_active_pane(window, cx); let Some(tab) = self.tabs.get_mut(active) else { return; @@ -161,25 +156,34 @@ impl Tty7App { 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, scroll: gpui::ScrollHandle::new(), - epoch, + 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) { @@ -1149,7 +1153,7 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject { 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("STAGED")), + DiffSource::Staged => branch_of(Some(t(L10nKey::ScmChipStaged))), DiffSource::Commit { rev, label } => SourceSubject { icon: "icons/git-commit.svg", text: short_rev(rev), diff --git a/src/ui/host_ops.rs b/src/ui/host_ops.rs index a72ca16c..60771ad3 100644 --- a/src/ui/host_ops.rs +++ b/src/ui/host_ops.rs @@ -241,6 +241,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 3611e1b0..34234025 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -845,6 +845,21 @@ pub fn translate_en(key: L10nKey) -> &'static str { 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", @@ -1314,7 +1329,6 @@ 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", @@ -1425,9 +1439,6 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::ScmStagedFileCount, "zero") => "No staged changes", (L10nKey::ScmStagedFileCount, "one") => "1 file staged", (L10nKey::ScmStagedFileCount, "other") => "{count} files staged", - (L10nKey::PanelUntracked, "zero") => "0 untracked", - (L10nKey::PanelUntracked, "one") => "1 untracked", - (L10nKey::PanelUntracked, "other") => "{count} untracked", (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 0189df6d..1d0af07d 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -895,6 +895,22 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { 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 => "ステージを取り消す", @@ -1359,7 +1375,6 @@ 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 について", @@ -1468,9 +1483,6 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::ScmStagedFileCount, "zero") => "ステージされた変更はありません", (L10nKey::ScmStagedFileCount, "one") => "1 個のファイルがステージされました", (L10nKey::ScmStagedFileCount, "other") => "{count} 個のファイルがステージされました", - (L10nKey::PanelUntracked, "zero") => "未追跡 0", - (L10nKey::PanelUntracked, "one") => "未追跡 1", - (L10nKey::PanelUntracked, "other") => "未追跡 {count}", (L10nKey::PanelMoreChangedFiles, "zero") => { "… さらに変更されたファイル 0 個 — 表示するには `git diff` を実行してください" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 99869077..738b074b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -630,7 +630,6 @@ pub enum L10nKey { PanelNoChanges, PanelNoChangesHint, PanelMoreChangedFiles, - PanelUntracked, PanelSessionSubtitle, PanelProcessesSubtitle, PanelPortsSubtitle, @@ -660,6 +659,18 @@ pub enum L10nKey { ScmCommitStaged, ScmStashAll, ScmNothingToCommit, + ScmNetworkBusy, + ScmCommitNeedsMessage, + ScmDetailFilesFailed, + ScmTimeNow, + ScmTimeMinutes, + ScmTimeHours, + ScmTimeDays, + ScmTimeMonths, + ScmTimeYears, + ScmResetHardConfirm, + ScmReset, + ScmChipStaged, ScmStage, ScmStageAll, ScmUnstage, @@ -1122,70 +1133,13 @@ pub enum L10nKey { /// **Delete a key from this list as soon as something renders it.** #[allow(dead_code)] const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ - 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::ScmStage, - L10nKey::ScmStageAll, - L10nKey::ScmUnstage, - L10nKey::ScmUnstageAll, - L10nKey::ScmDiscard, - L10nKey::ScmDiscardAll, - L10nKey::ScmDiscardConfirm, - L10nKey::ScmOpenConflict, - L10nKey::ScmMarkResolved, - L10nKey::ScmUnrepresentablePath, - L10nKey::ScmPublishBranch, - L10nKey::ScmDetached, - L10nKey::ScmAmendBadge, - L10nKey::ScmSync, - L10nKey::ScmPush, - L10nKey::ScmPull, - L10nKey::ScmFetch, L10nKey::ScmCheckoutBranch, - L10nKey::ScmCreateBranch, + L10nKey::ScmCommitDetailTitle, + L10nKey::ScmCommitStaged, + L10nKey::ScmRefresh, + L10nKey::ScmResetToCommit, 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::ScmCherryPick, - L10nKey::ScmRevertCommit, - L10nKey::ScmResetToCommit, - L10nKey::ScmRefresh, - L10nKey::ScmTooManyChanges, - L10nKey::ScmOpenChanges, - L10nKey::ScmDiscardAllConfirm, - L10nKey::ScmAmendConfirm, - L10nKey::ScmOpMerge, - L10nKey::ScmOpRebase, - L10nKey::ScmOpCherryPick, - L10nKey::ScmOpRevert, - L10nKey::ScmOpBisect, - L10nKey::ScmOpAm, - L10nKey::ScmSwitchRepository, - L10nKey::DiffViewSplit, - L10nKey::DiffViewUnified, ]; pub fn set_locale(gui_language: &str) { @@ -1844,7 +1798,6 @@ mod tests { L10nKey::PanelNoChanges, L10nKey::PanelNoChangesHint, L10nKey::PanelMoreChangedFiles, - L10nKey::PanelUntracked, L10nKey::PanelSessionSubtitle, L10nKey::PanelProcessesSubtitle, L10nKey::PanelPortsSubtitle, @@ -2263,7 +2216,6 @@ mod tests { L10nKey::SettingsAliasesLinked, L10nKey::SettingsRulesOpenedWithConnection, L10nKey::SettingsOfflineMachines, - L10nKey::PanelUntracked, L10nKey::PanelMoreChangedFiles, L10nKey::ScmFilesChanged, L10nKey::ScmStagedFileCount, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index a7bf0395..cea743e6 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -808,7 +808,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGroupStaged => "暂存的更改", L10nKey::ScmGroupChanges => "更改", L10nKey::ScmGroupUntracked => "未跟踪", - L10nKey::ScmCommitPlaceholder => "写点什么改了…", + L10nKey::ScmCommitPlaceholder => "说说改了什么…", L10nKey::ScmCommitButton => "提交", L10nKey::ScmCommitAllButton => "提交全部", L10nKey::ScmCommitAmendButton => "提交(修订)", @@ -818,6 +818,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { 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 => "取消暂存", @@ -1251,7 +1266,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpErrorUnsafeRemoteName => "拒绝不安全的远程名称 {name}", L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", - L10nKey::PanelUntracked => "{count} 个未跟踪文件", L10nKey::ScmFilesChanged => "{count} 个文件改动", L10nKey::ScmStagedFileCount => "已暂存 {count} 个文件", L10nKey::AppMenuAbout => "关于 tty7", @@ -1358,9 +1372,6 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::ScmStagedFileCount, "zero") => "没有暂存的更改", (L10nKey::ScmStagedFileCount, "one") => "已暂存 1 个文件", (L10nKey::ScmStagedFileCount, "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::PanelMoreChangedFiles, "other") => { diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index a91441f0..07f59a2a 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -124,7 +124,7 @@ impl Tty7App { PromptLevel::Warning, &confirm_question(&op, loss), None, - &[t(L10nKey::Cancel), confirm_verb(loss)], + &[t(L10nKey::Cancel), confirm_verb(&op, loss)], cx, ); cx.spawn_in(window, async move |app, cx| { @@ -465,6 +465,12 @@ pub(crate) fn split_upstream(upstream: &str) -> Option<(&str, &str)> { /// 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 @@ -476,7 +482,10 @@ fn confirm_question(op: &GitOp, loss: Destructive) -> String { } } -fn confirm_verb(loss: Destructive) -> &'static str { +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), diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index 40b4d969..5395faf6 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -241,7 +241,12 @@ impl Tty7App { if let Some(commit) = commit { open.commit = Some(Arc::new(commit)); } - open.files = Some(Arc::new(files.unwrap_or_default())); + 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(); }, ); @@ -531,7 +536,12 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let Some(files) = detail.files.clone() else { - return self.detail_note(t(L10nKey::PanelLoading).to_string(), cx); + 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 @@ -649,7 +659,7 @@ impl Tty7App { 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) + let selected = self.diff_overlay_focus(detail.repo.host, &detail.repo.root, source) == Some(file.path.as_str()); h_flex() diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index b12c3e37..52e3c097 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -74,6 +74,10 @@ use crate::ui::scm::state::RepoKey; /// 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. /// @@ -314,6 +318,14 @@ struct GraphPaint { /// 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, } @@ -419,6 +431,11 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { // 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. @@ -426,7 +443,7 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { window.paint_quad(quad( dot(r), Corners::all(px(r)), - p.surface, + hole, Edges::all(px(GRAPH_LINE_W)), ink, BorderStyle::Solid, @@ -437,7 +454,7 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { window.paint_quad(quad( dot(GRAPH_DOT_R), Corners::all(px(GRAPH_DOT_R)), - p.surface, + hole, Edges::all(px(GRAPH_LINE_W)), ink, BorderStyle::Solid, @@ -527,7 +544,7 @@ impl Tty7App { 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(), cx), + Some(page) => self.graph_body(repo, &page, query.as_deref(), height, cx), }; let (backing, handle) = self.graph_resize(ceiling, cx); @@ -631,6 +648,29 @@ impl Tty7App { ); } + /// 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()?; @@ -693,11 +733,15 @@ impl Tty7App { 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; @@ -710,22 +754,31 @@ impl Tty7App { // 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: Vec = match query { - None => (0..page.commits.len()).collect(), - Some(q) => (0..page.commits.len()) - .filter(|i| matches_query(&page.commits[*i], q)) - .collect(), - }; + 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().children( - rows.iter() + 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() @@ -736,6 +789,7 @@ impl Tty7App { .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, @@ -744,7 +798,14 @@ impl Tty7App { // 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(cx.global::().sidebar.base).into(), + 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( @@ -866,12 +927,15 @@ impl Tty7App { }) .on_click(cx.listener({ let repo = repo.clone(); - let oid = oid.clone(); // The row already holds everything the detail view renders, so - // it hands its own commit over and no `git show` is run. - let seed = commit.clone(); + // 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| { - this.graph_open_commit(repo.clone(), oid.clone(), Some(seed.clone()), cx) + let seed = page.commits[i].clone(); + this.graph_open_commit(repo.clone(), seed.oid.clone(), Some(seed), cx) } })) .context_menu({ diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index e7be760f..31cf96b9 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -218,6 +218,19 @@ impl Tty7App { 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 @@ -869,7 +882,7 @@ impl Tty7App { // does. .text_color(if live { fg } else { muted }) .disabled(!live) - .when(!live, |b| b.tooltip(t(L10nKey::ScmNothingToCommit))) + .when(!live, |b| b.tooltip(t(plan.reason))) .on_click(cx.listener(move |this, _, window, cx| { this.scm_commit( repo_for_button.clone(), @@ -1292,8 +1305,9 @@ impl Tty7App { let path = entry.path.as_str().to_string(); let (name, dir) = split_display_path(&path); let (letter, deco) = row_status(entry, group); - let selected = self.diff_overlay_focus(repo.host, &repo.root) == Some(path.as_str()); 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, @@ -1875,6 +1889,10 @@ pub(crate) fn operation_label(op: RepoOperation) -> L10nKey { 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. @@ -1894,9 +1912,19 @@ pub(crate) fn commit_plan(status: &WorkingTreeStatus, amend: bool, message: &str 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: (staged || tracked_edits || amend) && (has_message || amend), + enabled: something && (has_message || amend || merging), + reason: if something { + L10nKey::ScmCommitNeedsMessage + } else { + L10nKey::ScmNothingToCommit + }, } } diff --git a/src/ui/scm/path.rs b/src/ui/scm/path.rs index 4f730595..c8485d46 100644 --- a/src/ui/scm/path.rs +++ b/src/ui/scm/path.rs @@ -62,19 +62,25 @@ 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 => "now".to_string(), - d if d < HOUR => format!("{}m", d / MINUTE), - d if d < DAY => format!("{}h", d / HOUR), - d if d < MONTH => format!("{}d", d / DAY), - d if d < YEAR => format!("{}mo", d / MONTH), - d => format!("{}y", d / YEAR), + 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), } } @@ -155,6 +161,7 @@ mod tests { #[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"); @@ -174,7 +181,21 @@ mod tests { #[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 index a4b1f8b3..d31e34f0 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -123,6 +123,30 @@ impl ScmPanelState { 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("") } @@ -175,6 +199,11 @@ pub(crate) struct GraphState { /// 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. @@ -219,6 +248,11 @@ pub(crate) struct CommitDetailView { /// 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, @@ -238,6 +272,7 @@ impl CommitDetailView { loaded: false, commit: seed.map(Arc::new), files: None, + files_failed: false, body_expanded: false, } } From feb027da1fea58b7eaa8a313d81e7cb43631f491 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:06:15 +0800 Subject: [PATCH 36/36] feat(scm): show an untracked file's content when its row is opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focusing an untracked file in the diff overlay used to fall through to the names-only "Untracked files (N)" card — git has no patch for a file it does not know, and `--no-index` needs a null device whose spelling is platform business. The overlay now reads the file's own bytes (lazily, only the focused file, 4 MiB cap) and synthesizes the card a parsed added-file patch would produce: every line an addition, new-side numbers, true counts past the single-file budget, git's own NUL-in-the-first-8000-bytes binary rule. A fresh snapshot clears the preview so an edit shows up on the same cadence a tracked file's does; a failed read says so instead of showing an empty file. Found in manual acceptance of the panel. --- crates/tty7-core/src/core/git/diff.rs | 88 ++++++++++++++ src/ui/diff_overlay.rs | 163 +++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 953c7415..cb27a3a3 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -374,6 +374,55 @@ 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; @@ -1078,6 +1127,45 @@ Binary files a/img.png and b/img.png differ 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. diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 5caf1658..aba3fa96 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -15,6 +15,11 @@ use crate::terminal::git_diff::{ 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}; @@ -41,6 +46,14 @@ pub(crate) struct DiffOverlayState { 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. @@ -163,6 +176,8 @@ impl Tty7App { loading: false, expanded: HashMap::new(), focus, + preview: None, + preview_loading: None, scroll: gpui::ScrollHandle::new(), epoch: None, }); @@ -292,6 +307,9 @@ impl Tty7App { Some(snap) => DiffLoad::Ready(Arc::clone(snap)), None => DiffLoad::NotARepo, }; + // 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 { @@ -346,10 +364,11 @@ impl Tty7App { } 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 { @@ -361,6 +380,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, @@ -605,6 +638,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() @@ -1208,6 +1341,17 @@ fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option(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; @@ -1740,6 +1884,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 {