mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod diff_overlay;
|
||||
pub mod forwards;
|
||||
pub mod hints;
|
||||
pub mod home;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user