diff --git a/src/terminal/git_diff.rs b/src/terminal/git_diff.rs new file mode 100644 index 00000000..b11c9802 --- /dev/null +++ b/src/terminal/git_diff.rs @@ -0,0 +1,516 @@ +//! The full working-tree diff behind the sidebar's `+N −N` counts: `git diff +//! HEAD` parsed into files → hunks → lines, for the read-only diff overlay +//! (see [`crate::ui::diff_overlay`]) that covers the terminal when the user +//! clicks a tab row's git line. +//! +//! Same discipline as [`git_status`](crate::terminal::git_status): plain +//! shell-outs run on a background executor by the caller, read-only via +//! `GIT_OPTIONAL_LOCKS=0` (through the shared [`git_status::git`] helper), and +//! never trusted to be fast — the UI shows the previous snapshot (or a loading +//! state) until a probe lands. + +use std::path::{Path, PathBuf}; + +use crate::terminal::git_status; + +/// Cap on parsed diff lines per file. A generated lockfile or vendored blob +/// can be tens of thousands of lines; past this the file's hunks stop and the +/// overlay shows a "truncated" notice instead of building a giant element +/// tree. Generous enough that real hand-written changes never hit it. +pub const MAX_LINES_PER_FILE: usize = 2000; + +/// A file's added+removed size at which the overlay collapses it by default +/// (GitHub's "Load diff" treatment) — the user can still expand it by click. +pub const AUTO_COLLAPSE_LINES: u32 = 400; + +/// One parsed `git diff HEAD` for a repo, plus the untracked files `diff` +/// itself can't see. This is the overlay's whole model. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct DiffSnapshot { + /// The work-tree root the diff was taken in. + pub root: PathBuf, + /// Branch name (or short sha when detached) — the overlay's title. + pub branch: String, + /// Changed tracked files, in `git diff` order. + pub files: Vec, + /// Untracked (new, un-added) paths, repo-relative. Listed by name only: + /// `git diff HEAD` has no blob to diff them against, and agents create + /// files constantly — hiding them would make the overlay look like it + /// lost work. + pub untracked: Vec, +} + +impl DiffSnapshot { + /// Total added/removed line counts across all files — the overlay's + /// header numbers, matching the sidebar's `+N −N` by construction (both + /// sum per-file counts of the same `HEAD` diff). + pub fn totals(&self) -> (u32, u32) { + self.files + .iter() + .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) + } +} + +/// How a file changed vs `HEAD` — drives the status glyph in its header row. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FileStatus { + Added, + Modified, + Deleted, + Renamed, +} + +/// One changed file: its header-row facts plus the parsed hunks. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct FileDiff { + /// New path (repo-relative); for a deletion, the old path. + pub path: String, + /// The pre-rename path, only when `status == Renamed`. + pub old_path: Option, + pub status: FileStatus, + /// Lines added / removed in this file (counted from the parsed hunks). + pub added: u32, + pub removed: u32, + /// Binary file — no hunks, the header row says "binary" instead. + pub binary: bool, + /// Hunk parsing stopped at [`MAX_LINES_PER_FILE`]; the overlay appends a + /// "truncated" footer under the last hunk. + pub truncated: bool, + pub hunks: Vec, +} + +/// One `@@` hunk: its header line (kept verbatim, function context and all) +/// and the diff lines under it. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Hunk { + /// The full `@@ -a,b +c,d @@ …` line as git printed it. + pub header: String, + pub lines: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LineKind { + Context, + Added, + Removed, +} + +/// One diff line with the gutter numbers it carries: an added line has only a +/// new number, a removed line only an old one, context both. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct DiffLine { + pub kind: LineKind, + pub old_no: Option, + pub new_no: Option, + /// The line's text, without the leading `+`/`-`/space marker. + pub text: String, +} + +/// Probe the full diff snapshot for `cwd`, or `None` when it isn't inside a +/// git work tree. Blocking (three `git` shell-outs) — call it on a background +/// executor. +pub fn probe(cwd: &Path) -> Option { + if !cwd.exists() { + return None; + } + // Doubles as the "is this a repo" gate, same as the status probe. + let root = git_status::git(cwd, &["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); + let branch = git_status::branch_name(cwd)?; + // `-M` folds a delete+add pair back into one rename entry; `--no-ext-diff` + // keeps a configured external diff tool from replacing the parseable + // unified format. A failed diff (e.g. racing a concurrent git write) still + // yields a snapshot — an empty file list with the branch — rather than + // hiding the overlay; the next refresh fills it in. + let files = git_status::git(cwd, &["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"]) + .map(|out| parse_unified(&out)) + .unwrap_or_default(); + // `--full-name` pins paths to the repo root regardless of which + // subdirectory the pane sits in, matching the diff's path space. + let untracked = git_status::git( + cwd, + &["ls-files", "--others", "--exclude-standard", "--full-name"], + ) + .map(|out| out.lines().map(str::to_string).collect()) + .unwrap_or_default(); + Some(DiffSnapshot { + root, + branch, + files, + untracked, + }) +} + +/// Parse `git diff` unified output into per-file structures. Tolerant by +/// construction: unrecognized metadata lines between the `diff --git` header +/// and the first hunk (modes, index, similarity) are simply skipped, so a git +/// version printing extra headers degrades to "fewer facts", never a panic. +pub fn parse_unified(out: &str) -> Vec { + let mut files: Vec = Vec::new(); + // Line-number counters for the hunk currently being filled. + let (mut old_no, mut new_no) = (0u32, 0u32); + // Lines consumed by the current file's hunks, for the per-file cap. + let mut file_lines = 0usize; + + for line in out.lines() { + if let Some(rest) = line.strip_prefix("diff --git ") { + let (old_p, new_p) = parse_git_header_paths(rest); + 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: false, + hunks: Vec::new(), + }); + file_lines = 0; + continue; + } + let Some(file) = files.last_mut() else { + continue; // preamble before any header (shouldn't happen) + }; + // ── File-level metadata between the header and the first hunk ────── + if line.starts_with("new file mode") { + file.status = FileStatus::Added; + continue; + } + if line.starts_with("deleted file mode") { + file.status = FileStatus::Deleted; + continue; + } + if line.starts_with("rename from ") { + file.status = FileStatus::Renamed; + continue; + } + if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { + file.binary = true; + continue; + } + // `--- a/x` / `+++ b/x` repeat what the header said; `rename to`, + // `index`, modes and similarity scores add nothing we render. But only + // skip them *outside* hunk bodies — a removed line legitimately starts + // with `--- ` inside one. + if file.hunks.is_empty() + && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) + && !line.starts_with("@@") + { + continue; + } + // ── Hunks ─────────────────────────────────────────────────────────── + if line.starts_with("@@") { + if file.truncated { + continue; // past the cap: swallow the rest of this file + } + let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0)); + old_no = o; + new_no = n; + file.hunks.push(Hunk { + header: line.to_string(), + lines: Vec::new(), + }); + continue; + } + if file.hunks.is_empty() { + continue; // stray content outside any hunk + } + 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..]), + // `\ No newline at end of file` and anything else: not a diff line. + _ => continue, + }; + // Count added/removed *before* the truncation gate: the cap is about + // element volume, but the header numbers must stay honest, so lines + // past the cap still count even though they're never kept. + match kind { + LineKind::Added => file.added += 1, + LineKind::Removed => file.removed += 1, + LineKind::Context => {} + } + if file.truncated { + continue; + } + file_lines += 1; + if file_lines > MAX_LINES_PER_FILE { + file.truncated = true; + continue; + } + let Some(hunk) = file.hunks.last_mut() else { + continue; + }; + let (o, n) = match kind { + LineKind::Added => { + let n = new_no; + new_no += 1; + (None, Some(n)) + } + LineKind::Removed => { + let o = old_no; + old_no += 1; + (Some(o), None) + } + LineKind::Context => { + let (o, n) = (old_no, new_no); + old_no += 1; + new_no += 1; + (Some(o), Some(n)) + } + }; + hunk.lines.push(DiffLine { + kind, + old_no: o, + new_no: n, + text: text.to_string(), + }); + } + // A truncated file still counts +/− for its whole diff (the loop above + // keeps counting past the cap), so totals stay consistent with numstat. + files +} + +/// Whether a line can only belong to a hunk body (`+`/`-`/space/`\` lead). +fn is_hunk_line(line: &str) -> bool { + matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() +} + +/// Split the `a/old b/new` tail of a `diff --git` header into the two paths. +/// +/// Plain names split on the ` b/` separator; paths with spaces work because +/// git quotes *those* (`"a/x y" "b/x y"`), handled by the quoted branch. A +/// path containing a literal ` b/` unquoted is ambiguous in git's own format — +/// we take the last occurrence, matching git's convention of the `b/` side +/// naming the current file. +fn parse_git_header_paths(rest: &str) -> (String, String) { + // Quoted form: "a/path with spaces" "b/path with spaces". + 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)); + } + // Unsplittable — show the whole tail rather than nothing. + (rest.to_string(), rest.to_string()) +} + +/// Parse up to two double-quoted strings (git's C-style quoting, minus octal +/// escapes — good enough for spaces, the common case). +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 +} + +/// Drop the `a/` / `b/` prefix git puts on header paths. +fn strip_prefix_ab(p: &str) -> String { + p.strip_prefix("a/") + .or_else(|| p.strip_prefix("b/")) + .unwrap_or(p) + .to_string() +} + +/// The old/new start line numbers from a `@@ -a,b +c,d @@` header. +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 +"; + + /// The sample covers modify / add / delete / binary; statuses, counts, and + /// hunk line numbers all land where the unified format says they should. + #[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); + // Context line carries both numbers, tracking the hunk starts. + 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;"); + // Trailing context resumes both counters. + 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()); + } + + /// Renames keep both paths and don't show phantom +/− lines. + #[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)); + } + + /// Quoted headers (paths with spaces) resolve to the unquoted paths. + #[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); + } + + /// A `--- ` *content* line inside a hunk is a removed line, not metadata. + #[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); + // Raw `---- a heading rule` = marker `-` + content `--- a heading rule`: + // content that *itself* starts with `--- ` must not be eaten as metadata. + assert_eq!(lines[1].text, "--- a heading rule"); + } + + /// Past the per-file cap the hunks stop growing and the file is flagged, + /// but the +/− counts keep counting so the header stays honest. + #[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!(files[0].truncated); + 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); + } + + /// `\ No newline at end of file` markers are skipped, not rendered. + #[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)); + } + + /// Totals sum per-file counts. + #[test] + fn snapshot_totals() { + let snap = DiffSnapshot { + files: parse_unified(SAMPLE), + ..Default::default() + }; + assert_eq!(snap.totals(), (4, 2)); + } +} diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index f10fdfed..dc28d5b7 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -149,8 +149,10 @@ impl GitStatusCache { } } -/// The current branch name, or a short sha for a detached HEAD. -fn branch_name(cwd: &Path) -> Option { +/// The current branch name, or a short sha for a detached HEAD. Shared with +/// [`git_diff`](crate::terminal::git_diff), which fronts its overlay with the +/// same branch label the sidebar row shows. +pub(crate) fn branch_name(cwd: &Path) -> Option { // On a branch — even before the first commit — `symbolic-ref` names it. if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { let name = out.trim(); @@ -186,8 +188,9 @@ fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> { /// Run `git -C ` and return stdout on success, `None` on a /// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read /// truly read-only; stdin is nulled so a misconfigured git can't block on a -/// prompt. -fn git(cwd: &Path, args: &[&str]) -> Option { +/// prompt. Shared with [`git_diff`](crate::terminal::git_diff) so every git +/// read in the app goes through the same lock-free, prompt-proof invocation. +pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option { let out = Command::new("git") .arg("-C") .arg(cwd) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 3b64a032..34f06527 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -21,6 +21,7 @@ pub mod element; pub mod fps; mod fuzzy; mod generator; +pub(crate) mod git_diff; pub(crate) mod git_status; mod highlight; mod history; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7632b14f..28a7a63a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1038,6 +1038,16 @@ impl TerminalView { .status_for(cwd) } + /// The cwd the pane's git line reads from — the same path [`git_status`] + /// resolves through, so the diff overlay opened from that line probes the + /// identical repo (not a fresh foreground-cwd syscall that could disagree + /// mid-command). `None` outside a repo-probe-worthy state. + /// + /// [`git_status`]: Self::git_status + pub fn git_status_cwd(&self) -> Option<&std::path::Path> { + self.git_status_cwd.as_deref() + } + /// The current grid selection as text, if any non-blank one exists — the /// source for "Agent: Send Selection". pub fn selection_text(&self) -> Option { diff --git a/src/ui/app.rs b/src/ui/app.rs index 1301153b..9e686d93 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -328,6 +328,11 @@ pub struct Tty7App { /// (not a tab), so it covers the tab rail / title bar and never clutters the /// tab list. Holds all the settings widget state + its subscriptions. settings: Option, + /// `Some` while the working-tree diff overlay is open (clicked from a + /// sidebar row's git line). Covers the terminal body only — the sidebar + /// stays visible so other repos' git lines remain one click away. See + /// [`crate::ui::diff_overlay`]. + pub(crate) diff_overlay: Option, /// In-pane native-SSH auth / host-key sheet state (WS3). Holds the active /// prompt (keyed to the pane that raised it), its input widgets, and /// dismissable banners. Empty when no prompt is pending. @@ -449,10 +454,15 @@ impl Tty7App { let config_watch = cx.observe_global::(|this, cx| this.reload_from_config(cx)); // Repaint when any pane's git probe lands in the shared cache — the // sidebar's branch/diff lines read from it, and the probing pane's own - // notify wouldn't re-render rows belonging to *other* panes. + // notify wouldn't re-render rows belonging to *other* panes. The open + // diff overlay rides the same signal: if the landed numbers disagree + // with what it shows, it re-probes the full diff. cx.default_global::(); let git_status_watch = - cx.observe_global::(|_, cx| cx.notify()); + cx.observe_global::(|this, cx| { + this.maybe_refresh_diff_overlay(cx); + cx.notify(); + }); // Any real keypress means "chord, not a bare hold": cancel the held-⌘ // tab badges and whatever reveal is pending (see `ui::hints`). let this = cx.weak_entity(); @@ -543,6 +553,7 @@ impl Tty7App { sidebar_search, _sidebar_search_sub: sidebar_search_sub, settings: None, + diff_overlay: None, ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx), ssh_close_confirm: None, window_bounds: window.window_bounds().get_bounds(), @@ -1484,7 +1495,7 @@ impl Tty7App { /// tab's `last_focused`, so `focus_active` can restore it when we come back. /// Call this before any transition that moves focus off the active tab /// (switching tabs, opening a focus-stealing overlay). - fn remember_active_pane(&mut self, window: &Window, cx: &App) { + pub(crate) fn remember_active_pane(&mut self, window: &Window, cx: &App) { let active = self.active; if let Some(tab) = self.tabs.get_mut(active) { if let Some(leaf) = tab.pane.focused_leaf(window, cx) { @@ -3437,7 +3448,11 @@ impl Render for Tty7App { // Live-SSH close-confirmation sheet (E3). .when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| { this.child(el) - }); + }) + // Working-tree diff overlay (clicked from a sidebar git line) — + // last child, so it paints over every pane-contextual element + // above. It covers only the body: the sidebar stays interactive. + .when_some(self.render_diff_overlay(cx), |this, el| this.child(el)); // The two layouts. Horizontal (default): a column of [title bar / body]. // Vertical: the rail is a full-height *left column* that reaches the very diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs new file mode 100644 index 00000000..7ca919c5 --- /dev/null +++ b/src/ui/diff_overlay.rs @@ -0,0 +1,560 @@ +//! The working-tree diff overlay: a read-only, GitHub-style unified diff that +//! covers the terminal area when the user clicks a sidebar row's git line +//! (`⎇ branch +N −N`). One scrolling column — per-file cards with collapsible +//! hunk bodies, plus an untracked-files section `git diff` itself can't show. +//! +//! Deliberately a *lens*, not a git client: no staging, no discard, no +//! side-by-side. The terminal keeps running underneath (the overlay covers +//! only the body area, never the sidebar, so other tabs' git lines stay +//! clickable to switch which repo is shown). Esc, the ✕, or re-clicking the +//! same git line closes it. +//! +//! Data comes from [`crate::terminal::git_diff`], probed off-thread on open +//! and re-probed automatically while open whenever the shared +//! [`GitStatusCache`](crate::terminal::git_status::GitStatusCache) lands a +//! snapshot whose branch or counts disagree with what's shown — so a finishing +//! command or agent turn refreshes the overlay through the exact trigger +//! machinery the sidebar numbers already use. + +use std::collections::HashSet; +use std::path::PathBuf; + +use gpui::{ + AnyElement, FocusHandle, FontWeight, KeyDownEvent, Window, div, prelude::*, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use crate::terminal::git_diff::{ + self, AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, LineKind, +}; +use crate::ui::app::Tty7App; + +/// What the overlay currently shows: probing, a parsed snapshot, or the +/// answer that the cwd stopped being a repo. +pub(crate) enum DiffLoad { + /// First probe still in flight. + Loading, + Ready(DiffSnapshot), + /// The probe came back "not a work tree" (repo deleted, dir gone). + NotARepo, +} + +/// State of the open diff overlay (`None` on [`Tty7App`] when closed). +pub(crate) struct DiffOverlayState { + /// The pane cwd the diff is probed from — the same path the clicked git + /// line resolved its status through, so overlay and sidebar agree on the + /// repo. Also the toggle key: re-clicking a line with this cwd closes. + pub(crate) cwd: PathBuf, + /// Focus target so Esc lands on the overlay's key handler. + pub(crate) focus_handle: FocusHandle, + pub(crate) load: DiffLoad, + /// A probe is currently in flight (initial or refresh). + pub(crate) loading: bool, + /// Files the user flipped away from their default collapse state (small + /// files default open, big/binary ones closed). Keyed by path so the set + /// survives a background refresh of the snapshot. + pub(crate) toggled: HashSet, +} + +impl Tty7App { + /// Open the diff overlay for `cwd` — or close it when it's already open + /// for that same cwd (the git line acts as a toggle). Opening for a + /// different cwd swaps the overlay's repo in place. + pub(crate) fn toggle_diff_overlay( + &mut self, + cwd: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if self.diff_overlay.as_ref().is_some_and(|o| o.cwd == cwd) { + self.close_diff_overlay(window, cx); + return; + } + // The overlay steals focus (it needs Esc); snapshot the active pane so + // closing lands back on the same terminal — same discipline as Settings. + self.remember_active_pane(window, cx); + let focus_handle = cx.focus_handle(); + self.diff_overlay = Some(DiffOverlayState { + cwd, + focus_handle: focus_handle.clone(), + load: DiffLoad::Loading, + loading: false, + toggled: HashSet::new(), + }); + window.focus(&focus_handle, cx); + self.spawn_diff_probe(cx); + cx.notify(); + } + + /// Close the overlay (Esc, ✕, or the toggle) and give focus back to the + /// active terminal. + pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context) { + if self.diff_overlay.take().is_some() { + self.focus_active(window, cx); + cx.notify(); + } + } + + /// Kick off an off-thread full-diff probe for the overlay's cwd. In-flight + /// dedup is a simple flag: refresh triggers while one flies are dropped — + /// the status cache will fire again on the next real change, and a + /// just-landed diff is fresh enough. + fn spawn_diff_probe(&mut self, cx: &mut Context) { + let Some(overlay) = self.diff_overlay.as_mut() else { + return; + }; + if overlay.loading { + return; + } + overlay.loading = true; + let cwd = overlay.cwd.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn({ + let cwd = cwd.clone(); + async move { git_diff::probe(&cwd) } + }) + .await; + let _ = this.update(cx, |app, cx| { + // Guarded by cwd: if the overlay was closed, or swapped to + // another repo while we flew, this landing is obsolete. + let Some(overlay) = app.diff_overlay.as_mut().filter(|o| o.cwd == cwd) else { + return; + }; + overlay.loading = false; + overlay.load = match result { + Some(snap) => DiffLoad::Ready(snap), + None => DiffLoad::NotARepo, + }; + cx.notify(); + }); + }) + .detach(); + } + + /// Re-probe the open overlay when the shared status cache learned + /// something newer than what's shown — called from the app's + /// `observe_global::` hook, i.e. on the very triggers + /// (command end, agent-turn end, cwd change) that refresh the sidebar + /// numbers. Comparing branch + totals keeps the quiet case (unrelated + /// repo's probe landing) from spawning needless `git diff` runs. + pub(crate) fn maybe_refresh_diff_overlay(&mut self, cx: &mut Context) { + let Some(overlay) = self.diff_overlay.as_ref() else { + return; + }; + if overlay.loading { + return; + } + let DiffLoad::Ready(snap) = &overlay.load else { + return; // initial probe pending, or repo gone — nothing to diff against + }; + let Some(status) = cx + .try_global::() + .and_then(|cache| cache.status_for(&overlay.cwd)) + else { + return; + }; + if status.branch != snap.branch || (status.added, status.removed) != snap.totals() { + self.spawn_diff_probe(cx); + } + } + + /// The overlay element, or `None` when closed. Mounted as the topmost + /// absolute child of the body area — it covers the terminal but not the + /// sidebar or title strip. + pub(crate) fn render_diff_overlay(&self, cx: &mut Context) -> Option { + let overlay = self.diff_overlay.as_ref()?; + + let content = match &overlay.load { + DiffLoad::Loading => self.diff_message("Reading diff…", cx), + DiffLoad::NotARepo => self.diff_message("Not a git repository", cx), + DiffLoad::Ready(snap) if snap.files.is_empty() && snap.untracked.is_empty() => { + self.diff_message("Working tree clean", cx) + } + DiffLoad::Ready(snap) => self.diff_file_list(snap, &overlay.toggled, cx), + }; + + let header = self.diff_header(overlay, cx); + + Some( + v_flex() + .absolute() + .inset_0() + // Blocks mouse from reaching the terminal underneath. + .occlude() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .track_focus(&overlay.focus_handle) + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + if ev.keystroke.key.as_str() == "escape" { + this.close_diff_overlay(window, cx); + } + })) + .child(header) + .child(content) + .into_any_element(), + ) + } + + /// Top bar: branch, file/line totals, a subtle refresh spinner slot, ✕. + fn diff_header( + &self, + overlay: &DiffOverlayState, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let (branch, files, untracked, added, removed) = match &overlay.load { + DiffLoad::Ready(s) => { + let (a, r) = s.totals(); + (s.branch.clone(), s.files.len(), s.untracked.len(), a, r) + } + _ => (String::new(), 0, 0, 0, 0), + }; + h_flex() + .flex_shrink_0() + .h(px(40.)) + .px_3() + .gap_2() + .items_center() + .border_b_1() + .border_color(cx.theme().border) + .child( + gpui::svg() + .path("icons/git-branch.svg") + .flex_shrink_0() + .size(px(13.)) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(branch), + ) + .when(matches!(overlay.load, DiffLoad::Ready(_)), |bar| { + let mut summary = format!( + "{} changed file{}", + files, + if files == 1 { "" } else { "s" } + ); + if untracked > 0 { + summary.push_str(&format!(" · {untracked} untracked")); + } + bar.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + .when(added > 0, |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().success) + .child(format!("+{added}")), + ) + }) + .when(removed > 0, |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().danger) + .child(format!("−{removed}")), + ) + }) + }) + // A quiet "refreshing" hint while a re-probe flies over stale data. + .when(overlay.loading && matches!(overlay.load, DiffLoad::Ready(_)), |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("refreshing…"), + ) + }) + .child(div().flex_1()) + .child( + Button::new("diff-overlay-close") + .icon(IconName::Close) + .ghost() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.close_diff_overlay(window, cx); + })), + ) + } + + /// A centered single-line state (loading / clean / not-a-repo). + fn diff_message(&self, text: &'static str, cx: &Context) -> AnyElement { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element() + } + + /// The scrolling column of per-file diff cards plus the untracked section. + fn diff_file_list( + &self, + snap: &DiffSnapshot, + toggled: &HashSet, + cx: &mut Context, + ) -> AnyElement { + let mut list = v_flex().gap_3().p_4().w_full(); + for (idx, file) in snap.files.iter().enumerate() { + let expanded = file_expanded(file, toggled); + list = list.child(self.diff_file_card(idx, file, expanded, cx)); + } + if !snap.untracked.is_empty() { + list = list.child(self.diff_untracked_section(&snap.untracked, cx)); + } + div() + .id("diff-overlay-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(list) + .into_any_element() + } + + /// One file's card: a clickable header row and, when expanded, the hunks. + fn diff_file_card( + &self, + idx: usize, + file: &FileDiff, + expanded: bool, + cx: &mut Context, + ) -> AnyElement { + // Binary files and pure renames have no hunk body to reveal; their + // header is inert (no chevron, no click). + let expandable = !file.binary && !file.hunks.is_empty(); + 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), + }; + // `old → new` for renames, the plain path otherwise. + let shown_path = match &file.old_path { + Some(old) => format!("{old} → {}", file.path), + None => file.path.clone(), + }; + + let mut header = h_flex() + .id(("diff-file-header", idx)) + .w_full() + .items_center() + .gap_2() + .px_2p5() + .py_1p5() + .bg(cx.theme().secondary) + .when(expandable, |h| { + let path = file.path.clone(); + h.cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(overlay) = this.diff_overlay.as_mut() { + // Flip this file's override; removing an existing + // entry returns it to its default state. + if !overlay.toggled.remove(&path) { + overlay.toggled.insert(path.clone()); + } + cx.notify(); + } + })) + .child( + Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .small() + .text_color(cx.theme().muted_foreground), + ) + }) + .child( + div() + .flex_shrink_0() + .font_family(self.font_family.clone()) + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(glyph_color) + .child(glyph), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .font_family(self.font_family.clone()) + .child(shown_path), + ); + if file.binary { + header = header.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("binary"), + ); + } + if file.added > 0 { + header = header.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().success) + .child(format!("+{}", file.added)), + ); + } + if file.removed > 0 { + header = header.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().danger) + .child(format!("−{}", file.removed)), + ); + } + + let mut card = v_flex() + .w_full() + .border_1() + .border_color(cx.theme().border) + .rounded_md() + .overflow_hidden() + .child(header); + + if expanded { + let mut body = v_flex().w_full(); + for hunk in &file.hunks { + body = body.child( + div() + .w_full() + .px_2() + .py_0p5() + .bg(cx.theme().muted) + .text_xs() + .font_family(self.font_family.clone()) + .text_color(cx.theme().muted_foreground) + .truncate() + .child(hunk.header.clone()), + ); + for line in &hunk.lines { + body = body.child(self.diff_line_row(line, cx)); + } + } + if file.truncated { + body = body.child( + div() + .w_full() + .px_2() + .py_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "Diff truncated at {} lines — run `git diff` in the terminal for the rest.", + git_diff::MAX_LINES_PER_FILE + )), + ); + } + card = card.child(body); + } + card.into_any_element() + } + + /// One diff line: two right-aligned line-number gutters, then the marker + /// and text in the terminal font, the whole row tinted green/red. + fn diff_line_row(&self, line: &git_diff::DiffLine, cx: &Context) -> AnyElement { + let (marker, tint) = match line.kind { + LineKind::Added => ("+", Some(cx.theme().success.opacity(0.12))), + LineKind::Removed => ("−", Some(cx.theme().danger.opacity(0.12))), + LineKind::Context => (" ", None), + }; + let gutter = |no: Option| { + h_flex() + .flex_shrink_0() + .w(px(42.)) + .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() + // Fixed row height so blank diff lines don't collapse. + .h(px(19.)) + .items_center() + .text_xs() + .font_family(self.font_family.clone()) + .when_some(tint, |row, bg| row.bg(bg)) + .child(gutter(line.old_no)) + .child(gutter(line.new_no)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + // Tabs don't expand in UI text layout; four spaces keeps + // indentation readable. + .child(format!("{marker} {}", line.text.replace('\t', " "))), + ) + .into_any_element() + } + + /// The trailing "Untracked files" section: names only — `git diff HEAD` + /// has no blob to diff a never-added file against, but hiding them would + /// read as lost work (agents create files constantly). + fn diff_untracked_section(&self, untracked: &[String], cx: &Context) -> AnyElement { + let mut section = v_flex() + .w_full() + .border_1() + .border_color(cx.theme().border) + .rounded_md() + .overflow_hidden() + .child( + div() + .w_full() + .px_2p5() + .py_1p5() + .bg(cx.theme().secondary) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Untracked files ({})", untracked.len())), + ); + for path in untracked { + section = section.child( + h_flex() + .w_full() + .items_center() + .gap_2() + .px_2p5() + .py_1() + .text_xs() + .font_family(self.font_family.clone()) + .child( + div() + .flex_shrink_0() + .font_weight(FontWeight::BOLD) + .text_color(cx.theme().success) + .child("A"), + ) + .child(div().flex_1().min_w_0().truncate().child(path.clone())), + ); + } + section.into_any_element() + } +} + +/// Whether a file's body shows: small text diffs default open, big ones (and +/// anything the user explicitly flipped) invert via the `toggled` set. +fn file_expanded(file: &FileDiff, toggled: &HashSet) -> bool { + let default_open = file.added + file.removed <= AUTO_COLLAPSE_LINES; + default_open != toggled.contains(&file.path) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 689013f0..72856df4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,6 +7,7 @@ pub mod app; pub mod assets; +pub mod diff_overlay; pub mod forwards; pub mod hints; pub mod home; diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index a34869dd..a55567e1 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -93,13 +93,39 @@ impl Tty7App { // branch, then the working-tree diff as green `+N` / red `−N` // badges — a per-session branch row. Built here so the row can // grow to fit it (a non-repo pane keeps the compact two-line row). + // The line is also the diff overlay's toggle: the cwd it probes is + // the same one the status resolved through, so overlay and badge + // always describe the same repo. + let git_cwd = tab + .pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).git_status_cwd().map(|p| p.to_path_buf())); let git_line = tab.git_status(Some(window), cx).map(|g| { let mut line = h_flex() + .id(("sidebar-git", i)) .w_full() .items_center() .gap_1p5() .text_xs() .text_color(cx.theme().muted_foreground) + // Click to peek the full diff in an overlay over the + // terminal — without activating this row's tab, which is + // the point: glance at another session's changes while + // staying where you are. Quiet until hovered (the line + // brightens and shows a pointer), so the rail stays calm. + .when_some(git_cwd, |line, cwd| { + line.cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + // Swallow the press so the row/label + // handlers don't also activate the tab. + cx.stop_propagation(); + this.toggle_diff_overlay(cwd.clone(), window, cx); + }), + ) + }) .child( gpui::svg() .path("icons/git-branch.svg")