fix(diff): bound the untracked list and stop a moving default flipping toggles

Five findings from the review of the previous commit, all confirmed against the
source before acting.

The untracked list escaped every bound the previous commit added. `git ls-files
--others` reports the whole tree of anything not yet ignored, so a fresh clone
before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens
of thousands of paths — read through the buffering helper, retained without a
cap, ignored by `oversized()`, and rendered one non-virtualized row each. That
reaches the overlay without going through the diff at all, which is why the diff
budget never saw it. It is now streamed through `git_lines`, capped at
`MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most
`MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays
the true total via `untracked_total` — same split the diff side already makes
between what is retained and what is counted, because a count that shrank with
the budget would read as files having disappeared.

The overlay's expand state was an inversion set — "files flipped away from their
default" — which was correct while the default was per-file and stable. The
repo-wide `collapse_all` moves the default for every file at once, so any
refresh crossing the oversized threshold inverted every explicit choice
simultaneously: the two files the user had opened snapped shut and the rest
sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered
before the default is even computed, so nothing about the snapshot can reach it.
Covered by a test that asserts an explicitly opened and an explicitly closed
file both survive a transition in either direction while an untouched file
follows the default.

The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only
wrote `right_panel.diff` when the panel was the one waiting, so a probe the
overlay started was discarded even when the panel sat on that exact repo — the
overlay rendered the new snapshot while the panel kept the old one, same window,
same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed
separately: with probes deduped per cwd there is at most one in flight, so there
is no out-of-order overwrite to guard against.

The oversized banner reported `retained_lines()` as "diff lines", which after
the budget fires is what was kept, not what changed — it read "20000 diff lines"
directly under a header showing the exact +90000/-0. It now states
loaded-of-total and names which cap ate the difference, and lists each axis that
tripped the threshold so a big untracked list never reads as a claim that the
diff is big.

And the Changes panel deep-copied every untracked path String on every frame, on
the UI thread, for two `len()`/`is_empty()` reads — the same cost class the
`Arc` switch removed from the probe path.

995 tests pass, fmt clean, no new clippy warnings. The app was not built,
launched, or driven; visual acceptance is the owner's.

Refs #239.
This commit is contained in:
l0ng-ai
2026-07-29 16:10:46 +08:00
parent 3fd523e1b9
commit 9ffd1f89ef
4 changed files with 314 additions and 69 deletions
+6
View File
@@ -125,6 +125,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
of line text → 20 000 lines / 1.2 MiB — while `+N N` keeps counting the
whole diff, so the numbers stay exact and the overlay doesn't re-probe in a
loop chasing a total it can no longer reach.
- The untracked list escaped all of the above: `git ls-files --others` reports
every path not yet ignored, and one un-ignored `node_modules` reached the
overlay as tens of thousands of rows without touching the diff at all. It is
now streamed and capped like the diff, counts toward the oversized
threshold, and renders a bounded number of rows — while the count shown
stays the true total, so a capped list never reads as files having vanished.
- The 400-line auto-collapse rule was per-file, so sixty forty-line files all
opened at once (2400 side-by-side rows, none of them individually large).
Past a repo-wide total the overlay now opens with every file collapsed —
+86 -6
View File
@@ -65,9 +65,18 @@ pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100;
/// Hard ceiling on file cards the overlay builds at all. Past this the list is
/// cut and a "… and N more" line stands in for the tail, so the element tree
/// stays bounded no matter what the working tree looks like.
/// stays bounded no matter what the working tree looks like. Also bounds the
/// untracked section, which is the same one-row-per-path shape.
pub const MAX_RENDERED_FILES: usize = 300;
/// Repo-wide cap on retained untracked paths. `git ls-files --others` answers
/// with the whole tree of anything not yet ignored — a fresh clone before
/// `node_modules` / `target` / `.venv` reach `.gitignore` reports tens of
/// thousands of paths — and every one of them is an owned `String` here and a
/// row in the overlay. The count stays exact past the cap
/// ([`DiffSnapshot::untracked_total`]); only the retained list is bounded.
pub const MAX_UNTRACKED: usize = 500;
/// Why a file's hunks stop short of its real diff.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Truncation {
@@ -92,7 +101,17 @@ pub struct DiffSnapshot {
/// `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.
///
/// Capped at [`MAX_UNTRACKED`]; [`untracked_count`](Self::untracked_count)
/// is the honest total.
pub untracked: Vec<String>,
/// How many untracked paths `git ls-files --others` actually reported,
/// which is not `untracked.len()` once the cap bites. Same discipline as
/// [`totals`](Self::totals): what we *retain* is budgeted, what we *report*
/// stays exact, because a count that shrank with the budget would read as
/// files having disappeared. Read it through
/// [`untracked_count`](Self::untracked_count).
pub untracked_total: usize,
}
impl DiffSnapshot {
@@ -123,12 +142,28 @@ impl DiffSnapshot {
.sum()
}
/// The true number of untracked paths, whether or not the retained list was
/// capped. Falls back to the retained length so a snapshot built by hand
/// (tests, `..Default::default()`) can't under-report — the fallback is
/// never wrong, since `untracked_total` is only ever ≥ `untracked.len()`.
pub fn untracked_count(&self) -> usize {
self.untracked_total.max(self.untracked.len())
}
/// Whether this diff is too big to open expanded: every file starts
/// collapsed and the overlay leads with the "too large to render
/// efficiently" summary. Not a refusal — individual files still expand by
/// click, which is the escape hatch the summary points at.
///
/// Untracked paths count toward the file axis because they cost the same
/// thing a collapsed file card costs — one row each, in the same
/// non-virtualized list. A tree with an un-ignored `node_modules` is the
/// exact stall this is here to prevent, and it reaches the overlay through
/// `ls-files`, not through the diff. The summary names whichever axis
/// tripped, so a big untracked list never reads as a claim that the *diff*
/// is big.
pub fn oversized(&self) -> bool {
self.files.len() > AUTO_COLLAPSE_TOTAL_FILES
self.files.len() + self.untracked_count() > AUTO_COLLAPSE_TOTAL_FILES
|| self.retained_lines() > AUTO_COLLAPSE_TOTAL_LINES
}
@@ -221,19 +256,37 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
.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(
// subdirectory the pane sits in, matching the diff's path space. Capped for
// the same reason the diff is: `--others` walks everything not yet ignored,
// so one un-ignored dependency directory answers with tens of thousands of
// paths.
let mut untracked: Vec<String> = Vec::new();
let mut untracked_total = 0usize;
let ok = git_status::git(
host,
cwd,
&["ls-files", "--others", "--exclude-standard", "--full-name"],
)
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default();
.map(|out| {
for line in out.lines() {
untracked_total += 1;
if untracked.len() < MAX_UNTRACKED {
untracked.push(line.to_string());
}
}
});
if ok.is_none() {
// A failed listing is "we don't know", not "there are none" — same
// shape as the diff above.
untracked.clear();
untracked_total = 0;
}
Some(DiffSnapshot {
root,
branch,
files,
untracked,
untracked_total,
})
}
@@ -791,6 +844,33 @@ index 1..2 100644
assert_eq!((late.added, late.removed), (0, 1), "but the line counts");
}
/// The untracked cap keeps the retained list bounded while the reported
/// count stays exact — the same split the diff side already makes between
/// what is retained and what is counted.
#[test]
fn untracked_is_capped_but_counted() {
// What `probe` builds while streaming `ls-files --others`.
let mut untracked: Vec<String> = Vec::new();
let mut untracked_total = 0usize;
for i in 0..(MAX_UNTRACKED * 3) {
untracked_total += 1;
if untracked.len() < MAX_UNTRACKED {
untracked.push(format!("node_modules/p{i}/index.js"));
}
}
let snap = DiffSnapshot {
untracked,
untracked_total,
..Default::default()
};
assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded");
assert_eq!(
snap.untracked_count(),
MAX_UNTRACKED * 3,
"the count is not"
);
}
/// Measurement harness for issue #239 finding 1 — run with
/// `cargo test --release -- --ignored --nocapture bench_stream_vs_buffer`.
///
+214 -59
View File
@@ -28,7 +28,7 @@
//! file opens collapsed under a summary, and at most
//! [`MAX_RENDERED_FILES`] cards are built at all.
use std::collections::HashSet;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -84,10 +84,19 @@ pub(crate) struct DiffOverlayState {
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>,
/// Files the user has explicitly expanded (`true`) or collapsed (`false`),
/// keyed by path so the choice survives a background refresh of the
/// snapshot. Absent means "follow the default".
///
/// Absolute state, deliberately not an inversion set. It used to be a
/// `HashSet` of "files flipped away from their default", which was fine
/// while the default was per-file and stable — but the repo-wide
/// `collapse_all` moves the default for *every* file at once, so a refresh
/// that crossed the oversized threshold inverted every explicit choice
/// simultaneously: the two files the user had opened snapped shut and the
/// rest sprang open. Storing what the user actually wanted makes a moving
/// default unable to touch it.
pub(crate) expanded: HashMap<String, bool>,
/// When set, the overlay shows only this file (repo-relative path), always
/// expanded — the "click a row in the Changes panel" entry point. `None` is
/// the whole-tree view the git line opens. Kept as a path rather than an
@@ -188,7 +197,7 @@ impl Tty7App {
focus_handle: focus_handle.clone(),
load: seed,
loading: false,
toggled: HashSet::new(),
expanded: HashMap::new(),
focus,
});
window.focus(&focus_handle, cx);
@@ -315,16 +324,25 @@ impl Tty7App {
};
landed = true;
}
// The panel's wait is cleared by the answer it asked for, whoever
// actually ran it. `diff_cwd` is checked separately: the panel can have
// navigated to another repo — or another machine — since, in which case
// this result is stale and only the wait is over.
// The panel's *wait* is cleared by the answer it asked for, whoever
// actually ran it — that's what `diff_pending` is for, and clearing it
// on a result for a repo the panel has since left is what lets the
// render path notice nothing is cached and re-probe.
let key = (host, cwd.to_path_buf());
if self.right_panel.diff_pending.as_ref() == Some(&key) {
self.right_panel.diff_pending = None;
if self.right_panel.diff_cwd.as_ref() == Some(&key) {
self.right_panel.diff = Some(snap);
}
landed = true;
}
// The panel's *data*, though, is claimed by the repo key alone.
// Requiring the panel to have been the one waiting meant a probe the
// overlay started was thrown away for the panel even when it was
// sitting on that exact repo — so clicking the sidebar counts left the
// overlay showing the new snapshot and the panel still rendering the
// old one, in the same window. With probes deduped per repo there is at
// most one in flight, so there is no out-of-order overwrite to guard
// against.
if self.right_panel.diff_cwd.as_ref() == Some(&key) {
self.right_panel.diff = Some(snap);
landed = true;
}
if landed {
@@ -382,7 +400,7 @@ impl Tty7App {
self.diff_message("Working tree clean", cx)
}
DiffLoad::Ready(snap) => {
self.diff_file_list(snap, &overlay.toggled, focused_file(snap, overlay), cx)
self.diff_file_list(snap, &overlay.expanded, focused_file(snap, overlay), cx)
}
};
@@ -428,7 +446,7 @@ impl Tty7App {
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)
(s.branch.clone(), s.files.len(), s.untracked_count(), a, r)
}
_ => (String::new(), 0, 0, 0, 0),
};
@@ -601,7 +619,7 @@ impl Tty7App {
fn diff_file_list(
&self,
snap: &DiffSnapshot,
toggled: &HashSet<String>,
expanded: &HashMap<String, bool>,
focused: Option<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
@@ -627,12 +645,12 @@ impl Tty7App {
// A file opened by name was asked for explicitly — show its body
// even when it's over the auto-collapse threshold. The header still
// toggles, so a huge file can be folded back down.
let expanded = if focused == Some(idx) {
!toggled.contains(&file.path)
let is_expanded = if focused == Some(idx) {
expanded.get(&file.path).copied().unwrap_or(true)
} else {
file_expanded(file, toggled, oversized)
file_expanded(file, expanded, oversized)
};
list = list.child(self.diff_file_card(idx, file, expanded, cx));
list = list.child(self.diff_file_card(idx, file, is_expanded, cx));
}
if focused.is_none() && snap.files.len() > shown {
let rest = snap.files.len() - shown;
@@ -651,7 +669,7 @@ impl Tty7App {
}
// Untracked files are a property of the tree, not of the focused file.
if focused.is_none() && !snap.untracked.is_empty() {
list = list.child(self.diff_untracked_section(&snap.untracked, cx));
list = list.child(self.diff_untracked_section(snap, cx));
}
div()
.id("diff-overlay-scroll")
@@ -668,15 +686,40 @@ impl Tty7App {
/// file rows with their `+N N` are the part that still reads fine at this
/// size.
fn diff_oversized_notice(&self, snap: &DiffSnapshot, cx: &Context<Self>) -> AnyElement {
let mut text = format!(
"This diff is too large to render efficiently ({} changed files, {} diff lines). \
Every file is collapsed — expand individual files, or run `git diff` in the terminal.",
// Name every axis that contributes, so the banner never claims the diff
// is big when what is actually big is an un-ignored untracked tree.
let mut parts = vec![format!(
"{} changed file{}",
snap.files.len(),
snap.retained_lines(),
);
if snap.budget_exhausted() {
text.push_str(" Some files' contents were dropped to keep tty7 responsive.");
if snap.files.len() == 1 { "" } else { "s" }
)];
// Loaded-of-total, not the retained count alone: past the repo-wide
// budget `retained_lines` is what we kept, and printing that as "diff
// lines" contradicted the exact `+N N` in the header directly above —
// 20 000 against 90 000 on a big tree, with nothing to reconcile them.
let (added, removed) = snap.totals();
let total_lines = (added + removed) as usize;
let loaded = snap.retained_lines();
parts.push(if loaded < total_lines {
// Say which cap ate the difference. "20000 of 90000 loaded" on its
// own leaves the user guessing whether the rest is merely collapsed
// (one click away) or was never read (it isn't).
if snap.budget_exhausted() {
format!("{loaded} of {total_lines} diff lines loaded, the rest past tty7's budget")
} else {
format!("{loaded} of {total_lines} diff lines loaded")
}
} else {
format!("{total_lines} diff lines")
});
if snap.untracked_count() > 0 {
parts.push(format!("{} untracked", snap.untracked_count()));
}
let text = format!(
"This working tree is too large to render efficiently ({}). Every file is \
collapsed — expand individual files, or run `git diff` in the terminal.",
parts.join(", "),
);
div()
.w_full()
.px_2p5()
@@ -754,11 +797,12 @@ impl Tty7App {
.get_mut(active)
.and_then(|t| t.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());
}
// Record what the user now wants, not "differs from
// the default": `expanded` is the state this card is
// currently drawn in, so the click means the
// opposite of it, and that answer keeps holding even
// if the default later moves under it.
overlay.expanded.insert(path.clone(), !expanded);
cx.notify();
}
}))
@@ -968,13 +1012,23 @@ impl Tty7App {
/// 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 {
///
/// Bounded exactly like the file-card list above it, and for the same
/// reason: this is one non-virtualized row per path, and `ls-files
/// --others` on a tree whose dependency directory isn't ignored yet answers
/// with tens of thousands of them. The header count is the true total, so
/// capping the rows never makes files look gone.
fn diff_untracked_section(&self, snap: &DiffSnapshot, cx: &Context<Self>) -> AnyElement {
let total = snap.untracked_count();
let untracked = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)];
// Same filled-band-in-a-rounded-card shape as `diff_file_card`, so the
// header owns the corners it sits in. The rows below it paint no fill,
// which is why only the top pair is ever non-zero here.
// which is why only the top pair is ever non-zero here. Counted off the
// *total*, not the capped slice: a section with a "… and N more" tail
// still has rows under its header.
let header_corners = rounding::stack_corners(
0,
if untracked.is_empty() { 1 } else { 2 },
if total == 0 { 1 } else { 2 },
rounding::CARD_RADIUS,
rounding::HAIRLINE,
);
@@ -993,7 +1047,7 @@ impl Tty7App {
.bg(cx.theme().secondary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("Untracked files ({})", untracked.len())),
.child(format!("Untracked files ({total})")),
);
for path in untracked {
section = section.child(
@@ -1015,6 +1069,20 @@ impl Tty7App {
.child(div().flex_1().min_w_0().truncate().child(path.clone())),
);
}
if total > untracked.len() {
let rest = total - untracked.len();
section = section.child(
div()
.w_full()
.px_2p5()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"… and {rest} more — run `git status` in the terminal to see them.",
)),
);
}
section.into_any_element()
}
}
@@ -1038,18 +1106,25 @@ fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
Some(snap.files[idx].path.clone())
}
/// Whether a file's body shows: small text diffs default open, big ones (and
/// anything the user explicitly flipped) invert via the `toggled` set.
/// Whether a file's body shows.
///
/// `collapse_all` is the repo-wide override: past
/// An explicit choice in `expanded` is final: it is answered before the default
/// is even computed, so nothing about the snapshot can change it. That ordering
/// is the whole point — `collapse_all` is a repo-wide default that moves as the
/// working tree grows and shrinks, and a user who opened one file inside an
/// oversized diff must not have it snap shut the moment an agent reverts enough
/// lines to drop the tree back under the threshold.
///
/// Files the user never touched follow the default: small text diffs open, big
/// ones closed, and past
/// [`AUTO_COLLAPSE_TOTAL_LINES`](git_diff::AUTO_COLLAPSE_TOTAL_LINES) nothing
/// opens by default, because the per-file threshold can't see that forty
/// innocent files are about to expand at once. The user's explicit toggles
/// still win over it — that's the "expand individual files" the oversized
/// notice promises.
fn file_expanded(file: &FileDiff, toggled: &HashSet<String>, collapse_all: bool) -> bool {
let default_open = !collapse_all && file.added + file.removed <= AUTO_COLLAPSE_LINES;
default_open != toggled.contains(&file.path)
/// opens at all, because the per-file threshold can't see that forty innocent
/// files are about to expand at once.
fn file_expanded(file: &FileDiff, expanded: &HashMap<String, bool>, collapse_all: bool) -> bool {
if let Some(&want) = expanded.get(&file.path) {
return want;
}
!collapse_all && file.added + file.removed <= AUTO_COLLAPSE_LINES
}
/// Which column a split cell belongs to — picks the marker and tint.
@@ -1214,40 +1289,71 @@ mod tests {
}
}
/// An explicit choice map, for the tests below.
fn choices<const N: usize>(pairs: [(&str, bool); N]) -> HashMap<String, bool> {
pairs.into_iter().map(|(p, v)| (p.to_string(), v)).collect()
}
/// The per-file threshold on its own: a small file opens, a big one doesn't,
/// and an explicit toggle inverts either. Unchanged behaviour — this is the
/// small-working-tree case that must feel identical.
/// and an explicit choice overrides either. Unchanged behaviour — this is
/// the small-working-tree case that must feel identical.
#[test]
fn per_file_collapse_is_unchanged_below_the_repo_threshold() {
let small = small_file("small.rs", 10);
let big = small_file("big.rs", AUTO_COLLAPSE_LINES + 1);
let none = HashSet::new();
let none = HashMap::new();
assert!(file_expanded(&small, &none, false));
assert!(!file_expanded(&big, &none, false));
let toggled: HashSet<String> = ["small.rs".to_string(), "big.rs".to_string()].into();
assert!(!file_expanded(&small, &toggled, false));
assert!(file_expanded(&big, &toggled, false));
let picked = choices([("small.rs", false), ("big.rs", true)]);
assert!(!file_expanded(&small, &picked, false));
assert!(file_expanded(&big, &picked, false));
}
/// The repo-wide override: past the total threshold nothing opens by
/// default, however small each file is — the case a per-file rule can't see
/// (issue #239, finding 4). An explicit toggle still wins, which is what
/// (issue #239, finding 4). An explicit choice still wins, which is what
/// "expand individual files" in the oversized notice means.
#[test]
fn repo_wide_collapse_overrides_the_per_file_default() {
let small = small_file("small.rs", 10);
let none = HashSet::new();
let none = HashMap::new();
assert!(file_expanded(&small, &none, false));
assert!(!file_expanded(&small, &none, true), "collapsed en masse");
let toggled: HashSet<String> = ["small.rs".to_string()].into();
assert!(
file_expanded(&small, &toggled, true),
file_expanded(&small, &choices([("small.rs", true)]), true),
"the user's own click still opens it"
);
}
/// An explicit choice must survive the default moving under it — the defect
/// the inversion-set representation had. A refresh that crosses the
/// oversized threshold in either direction leaves every file the user
/// touched exactly as they left it, and moves only the ones they didn't.
#[test]
fn explicit_choices_survive_an_oversized_transition() {
let opened = small_file("opened.rs", 10);
let closed = small_file("closed.rs", 10);
let untouched = small_file("untouched.rs", 10);
// The user opened one file and shut another while the tree was oversized.
let picked = choices([("opened.rs", true), ("closed.rs", false)]);
for collapse_all in [true, false] {
assert!(
file_expanded(&opened, &picked, collapse_all),
"an explicitly opened file stays open (collapse_all={collapse_all})"
);
assert!(
!file_expanded(&closed, &picked, collapse_all),
"an explicitly closed file stays closed (collapse_all={collapse_all})"
);
}
// Only the file the user never touched follows the default.
assert!(!file_expanded(&untouched, &picked, true));
assert!(file_expanded(&untouched, &picked, false));
}
/// Sixty files of forty lines each never trip the per-file threshold (each
/// is a tenth of it), yet would open 2400 diff rows at once. `oversized`
/// catches it on the line axis, and with everything collapsed the overlay
@@ -1266,7 +1372,7 @@ mod tests {
);
assert!(snap.oversized());
let none = HashSet::new();
let none = HashMap::new();
let rows_expanded: usize = snap
.files
.iter()
@@ -1297,10 +1403,59 @@ mod tests {
..Default::default()
};
assert!(!snap.oversized());
let none = HashSet::new();
let none = HashMap::new();
assert!(snap.files.iter().all(|f| file_expanded(f, &none, false)));
}
/// A clean diff with a huge untracked list is oversized too. `ls-files
/// --others` reaches the overlay without going through the diff at all, so
/// an un-ignored `node_modules` produced thousands of rows past every bound
/// the rest of this change added.
#[test]
fn a_huge_untracked_list_is_oversized() {
let snap = DiffSnapshot {
files: vec![small_file("one.rs", 3)],
untracked: (0..git_diff::MAX_UNTRACKED)
.map(|i| format!("node_modules/p{i}/index.js"))
.collect(),
untracked_total: 40_000,
..Default::default()
};
assert!(snap.retained_lines() < git_diff::AUTO_COLLAPSE_TOTAL_LINES);
assert!(snap.files.len() < git_diff::AUTO_COLLAPSE_TOTAL_FILES);
assert!(snap.oversized(), "the untracked list alone must trip it");
}
/// The untracked section builds at most [`MAX_RENDERED_FILES`] rows while
/// still reporting the true total, so a capped list never reads as files
/// having vanished.
#[test]
fn untracked_rows_are_capped_but_the_count_stays_true() {
let snap = DiffSnapshot {
untracked: (0..git_diff::MAX_UNTRACKED)
.map(|i| format!("p{i}"))
.collect(),
untracked_total: 12_345,
..Default::default()
};
let rendered = snap.untracked.len().min(MAX_RENDERED_FILES);
assert_eq!(rendered, MAX_RENDERED_FILES);
assert_eq!(snap.untracked_count(), 12_345, "header count is honest");
assert_eq!(snap.untracked_count() - rendered, 12_045);
}
/// A snapshot built without the streaming probe (tests, `..Default`) must
/// not under-report: the count falls back to the retained length.
#[test]
fn untracked_count_falls_back_to_the_retained_length() {
let snap = DiffSnapshot {
untracked: vec!["a".into(), "b".into(), "c".into()],
..Default::default()
};
assert_eq!(snap.untracked_total, 0);
assert_eq!(snap.untracked_count(), 3);
}
/// However many files change, the overlay builds at most
/// [`MAX_RENDERED_FILES`] cards and says so.
#[test]
+8 -4
View File
@@ -1192,7 +1192,7 @@ impl Tty7App {
// so the diff borrow ends before `panel_title` takes `&mut cx`.
let count = match &self.right_panel.diff {
Some(Some(snap)) => {
let n = snap.files.len() + snap.untracked.len();
let n = snap.files.len() + snap.untracked_count();
(n > 0).then(|| n.to_string())
}
_ => None,
@@ -1219,7 +1219,11 @@ impl Tty7App {
.iter()
.map(|f| (f.path.clone(), f.added, f.removed))
.collect();
let untracked = snap.untracked.clone();
// The count, not the paths: this used to clone every untracked
// path String on every frame of this panel, on the UI thread,
// for two `len()`/`is_empty()` reads — the same cost class the
// `Arc` switch removed from the probe path.
let untracked = snap.untracked_count();
let focused = self.diff_overlay_focus(host.id(), &cwd).map(str::to_string);
// Rows inset themselves rather than the list, so the hover and
// selected capsules bleed a little past the text into the same
@@ -1298,7 +1302,7 @@ impl Tty7App {
}),
);
}
if !untracked.is_empty() {
if untracked > 0 {
list = list.child(
h_flex()
.items_center()
@@ -1314,7 +1318,7 @@ impl Tty7App {
div()
.text_size(px(11.5))
.text_color(cx.theme().muted_foreground)
.child(format!("{} untracked", untracked.len())),
.child(format!("{untracked} untracked")),
),
);
}