Merge pull request #92 from l0ng-ai/feat/sidebar-diff-overlay

feat(git): click a sidebar git line to open a working-tree diff overlay
This commit is contained in:
l0ng-ai
2026-07-15 22:01:03 +08:00
committed by GitHub
8 changed files with 1140 additions and 8 deletions
+516
View File
@@ -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<FileDiff>,
/// 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<String>,
}
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<String>,
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<Hunk>,
}
/// 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<DiffLine>,
}
#[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<u32>,
pub new_no: Option<u32>,
/// 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<DiffSnapshot> {
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<FileDiff> {
let mut files: Vec<FileDiff> = 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<String> = 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<String> {
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));
}
}
+7 -4
View File
@@ -149,8 +149,10 @@ impl GitStatusCache {
}
}
/// The current branch name, or a short sha for a detached HEAD.
fn branch_name(cwd: &Path) -> Option<String> {
/// 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<String> {
// 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 <cwd> <args>` 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<String> {
/// 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<String> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
+1
View File
@@ -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;
+10
View File
@@ -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<String> {
+19 -4
View File
@@ -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<SettingsState>,
/// `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<crate::ui::diff_overlay::DiffOverlayState>,
/// 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::<Config>(|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::<crate::terminal::git_status::GitStatusCache>();
let git_status_watch =
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|_, cx| cx.notify());
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|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
+560
View File
@@ -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<String>,
}
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<Self>,
) {
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<Self>) {
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<Self>) {
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::<GitStatusCache>` 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<Self>) {
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::<crate::terminal::git_status::GitStatusCache>()
.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<Self>) -> Option<AnyElement> {
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<Self>,
) -> 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<Self>) -> 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<String>,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> 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<Self>) -> 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<u32>| {
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<Self>) -> 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<String>) -> bool {
let default_open = file.added + file.removed <= AUTO_COLLAPSE_LINES;
default_open != toggled.contains(&file.path)
}
+1
View File
@@ -7,6 +7,7 @@
pub mod app;
pub mod assets;
pub mod diff_overlay;
pub mod forwards;
pub mod hints;
pub mod home;
+26
View File
@@ -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")