feat(scm): show an untracked file's content when its row is opened

Focusing an untracked file in the diff overlay used to fall through to
the names-only "Untracked files (N)" card — git has no patch for a
file it does not know, and `--no-index` needs a null device whose
spelling is platform business. The overlay now reads the file's own
bytes (lazily, only the focused file, 4 MiB cap) and synthesizes the
card a parsed added-file patch would produce: every line an addition,
new-side numbers, true counts past the single-file budget, git's own
NUL-in-the-first-8000-bytes binary rule. A fresh snapshot clears the
preview so an edit shows up on the same cadence a tracked file's does;
a failed read says so instead of showing an empty file.

Found in manual acceptance of the panel.
This commit is contained in:
l0ng-ai
2026-08-10 13:06:15 +08:00
parent 58d7ef5838
commit feb027da1f
2 changed files with 250 additions and 1 deletions
+88
View File
@@ -374,6 +374,55 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
probe_diff(host, cwd, &DiffRequest::default())
}
/// A whole file rendered as one addition — what an *untracked* file looks
/// like as a patch. git cannot produce this one: `diff` does not know the
/// file, and `--no-index` needs a null device whose spelling is platform
/// business. So the overlay reads the bytes and this builds the same model a
/// parsed patch would, on the same budget a real single-file patch gets —
/// `added` stays the true count past every truncation, like the parser's.
pub fn synthesize_added(path: &str, bytes: &[u8], budget: &DiffBudget) -> FileDiff {
// The same test git itself applies: a NUL anywhere in the first 8000
// bytes means binary.
let binary = bytes[..bytes.len().min(8000)].contains(&0);
let mut file = FileDiff {
path: path.to_string(),
old_path: None,
status: FileStatus::Added,
added: 0,
removed: 0,
binary,
truncated: None,
hunks: Vec::new(),
};
if binary {
return file;
}
let text = String::from_utf8_lossy(bytes);
let total = text.lines().count();
file.added = total as u32;
if total == 0 {
return file;
}
let mut lines = Vec::new();
for (i, line) in text.lines().enumerate() {
if i >= budget.max_lines_per_file {
file.truncated = Some(Truncation::PerFile);
break;
}
lines.push(DiffLine {
kind: LineKind::Added,
old_no: None,
new_no: Some(i as u32 + 1),
text: line.to_string(),
});
}
file.hunks.push(Hunk {
header: format!("@@ -0,0 +1,{total} @@"),
lines,
});
file
}
pub fn probe_diff(host: &dyn Host, root: &Path, req: &DiffRequest<'_>) -> Option<DiffSnapshot> {
if !req.source.revs_are_arguments() {
return None;
@@ -1078,6 +1127,45 @@ Binary files a/img.png and b/img.png differ
assert_eq!(raw[0].path, "中文名.txt");
}
/// The synthesized card for an untracked file mirrors what a parsed
/// added-file patch looks like: true counts past the budget, a hunk
/// header the renderer can show, git's own binary rule.
#[test]
fn an_untracked_file_synthesizes_as_one_addition() {
let file = synthesize_added("notes.md", b"one\ntwo\nthree\n", &DiffBudget::SINGLE_FILE);
assert_eq!(file.status, FileStatus::Added);
assert_eq!((file.added, file.removed), (3, 0));
assert!(!file.binary);
assert_eq!(file.hunks.len(), 1);
assert_eq!(file.hunks[0].header, "@@ -0,0 +1,3 @@");
let lines = &file.hunks[0].lines;
assert_eq!(lines.len(), 3);
assert!(lines.iter().all(|l| l.kind == LineKind::Added));
assert_eq!((lines[2].old_no, lines[2].new_no), (None, Some(3)));
assert_eq!(lines[2].text, "three");
let empty = synthesize_added("empty", b"", &DiffBudget::SINGLE_FILE);
assert_eq!(empty.added, 0);
assert!(empty.hunks.is_empty(), "no hunk for a file with no lines");
let binary = synthesize_added("blob.png", b"\x89PNG\x00\x01", &DiffBudget::SINGLE_FILE);
assert!(binary.binary);
assert!(binary.hunks.is_empty());
let over = "x\n".repeat(DiffBudget::SINGLE_FILE.max_lines_per_file + 5);
let over = synthesize_added("big.txt", over.as_bytes(), &DiffBudget::SINGLE_FILE);
assert_eq!(over.truncated, Some(Truncation::PerFile));
assert_eq!(
over.added as usize,
DiffBudget::SINGLE_FILE.max_lines_per_file + 5,
"the count stays true past the budget"
);
assert_eq!(
over.hunks[0].lines.len(),
DiffBudget::SINGLE_FILE.max_lines_per_file
);
}
/// git never quotes a path for a mere space, so a `diff --git` header
/// whose paths contain ` b/` cannot be split reliably — but the `rename
/// from`/`rename to` lines that follow name one path each, and they win.
+162 -1
View File
@@ -15,6 +15,11 @@ use crate::terminal::git_diff::{
self, AUTO_COLLAPSE_LINES, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff,
FileStatus, LineKind, MAX_RENDERED_FILES, Truncation,
};
/// How much of an untracked file the preview will read. Past this the card
/// says the read failed rather than showing a silently cut-off file — and the
/// line budget below cuts rendering long before this does anyway.
const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024;
use crate::ui::app::Tty7App;
use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
@@ -41,6 +46,14 @@ pub(crate) struct DiffOverlayState {
pub(crate) loading: bool,
pub(crate) expanded: HashMap<String, bool>,
pub(crate) focus: Option<String>,
/// A synthesized all-added card for a focused *untracked* file, keyed by
/// path; `None` in the value means the read failed. git has no patch for
/// an untracked file, so focusing one reads its bytes instead — lazily,
/// only for the file on screen, never for the whole list. Cleared when a
/// fresh snapshot lands, so an edit to the file shows up on the same
/// cadence a tracked file's does.
pub(crate) preview: Option<(String, Option<Arc<FileDiff>>)>,
pub(crate) preview_loading: Option<String>,
pub(crate) scroll: gpui::ScrollHandle,
/// The [`ScmData`](crate::terminal::git_data::ScmData) epoch this patch was
/// read at, for the two sources that can go stale.
@@ -163,6 +176,8 @@ impl Tty7App {
loading: false,
expanded: HashMap::new(),
focus,
preview: None,
preview_loading: None,
scroll: gpui::ScrollHandle::new(),
epoch: None,
});
@@ -292,6 +307,9 @@ impl Tty7App {
Some(snap) => DiffLoad::Ready(Arc::clone(snap)),
None => DiffLoad::NotARepo,
};
// A new snapshot restarts any untracked preview: the file may
// have changed with the tree, and the re-read costs one file.
overlay.preview = None;
landed = true;
}
if landed {
@@ -346,10 +364,11 @@ impl Tty7App {
}
pub(crate) fn render_diff_overlay(
&self,
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
self.spawn_untracked_preview_if_needed(cx);
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
let content = match &overlay.load {
@@ -361,6 +380,20 @@ impl Tty7App {
DiffLoad::Ready(snap) if empty_snapshot(snap) => {
self.diff_message(t(L10nKey::DiffWorkingTreeClean), cx)
}
// A focused *untracked* file has no patch in the snapshot; its
// card is synthesized from the file's own bytes — see `preview`.
DiffLoad::Ready(snap) if untracked_focus(snap, overlay.focus.as_deref()).is_some() => {
let path = untracked_focus(snap, overlay.focus.as_deref()).unwrap();
match &overlay.preview {
Some((held, Some(file))) if held == path => {
self.diff_preview_card(file.as_ref(), &overlay.scroll, cx)
}
Some((held, None)) if held == path => {
self.diff_message(t(L10nKey::DiffReadFailed), cx)
}
_ => self.diff_message(t(L10nKey::DiffReading), cx),
}
}
DiffLoad::Ready(snap) => self.diff_file_list(
snap,
&overlay.expanded,
@@ -605,6 +638,106 @@ impl Tty7App {
)
}
/// Dispatch the byte read behind an untracked file's preview, at most
/// once per (path, snapshot). Runs from `render`, so the guards are the
/// point: `preview` says the answer is in hand, `preview_loading` says it
/// is on the way.
fn spawn_untracked_preview_if_needed(&mut self, cx: &mut Context<Self>) {
let want = {
let overlay = self
.tabs
.get(self.active)
.and_then(|t| t.diff_overlay.as_ref());
match overlay {
Some(o) => match &o.load {
DiffLoad::Ready(snap) => {
untracked_focus(snap, o.focus.as_deref()).and_then(|path| {
let seen = o.preview.as_ref().is_some_and(|(held, _)| held == path)
|| o.preview_loading.as_deref() == Some(path);
(!seen).then(|| (o.host_id, snap.root.clone(), path.to_string()))
})
}
_ => None,
},
None => None,
}
};
let Some((host_id, root, path)) = want else {
return;
};
let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, host_id) else {
return;
};
let active = self.active;
if let Some(o) = self
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
{
o.preview_loading = Some(path.clone());
}
let read_path = root.join(&path);
let key_path = path.clone();
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
h.read_file(&read_path, MAX_PREVIEW_BYTES)
.ok()
.map(|bytes| {
Arc::new(git_diff::synthesize_added(
&path,
&bytes,
&git_diff::DiffBudget::SINGLE_FILE,
))
})
},
move |this, file, cx| {
let active = this.active;
let Some(o) = this
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
.filter(|o| o.host_id == host_id)
else {
return;
};
if o.preview_loading.as_deref() == Some(key_path.as_str()) {
o.preview_loading = None;
}
o.preview = Some((key_path.clone(), file));
cx.notify();
},
);
}
/// The one synthesized card, in the same scroll shell the file list uses.
fn diff_preview_card(
&self,
file: &FileDiff,
scroll: &gpui::ScrollHandle,
cx: &mut Context<Self>,
) -> AnyElement {
let mode = view_mode(cx);
let list = v_flex()
.gap_3()
.p_4()
.w_full()
// `usize::MAX` keeps the element ids clear of the real list's.
.child(self.diff_file_card(usize::MAX, file, true, mode, cx));
crate::ui::scrollbar::with_vertical_scrollbar(
"diff-overlay-scrollbar",
div()
.id("diff-overlay-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.track_scroll(scroll)
.child(list),
scroll,
)
}
fn diff_message(&self, text: &'static str, cx: &Context<Self>) -> AnyElement {
div()
.flex_1()
@@ -1208,6 +1341,17 @@ fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option<usize
snap.files.iter().position(|f| f.path == path)
}
/// The focused path, when it is an *untracked* file — one the snapshot lists
/// by name but holds no patch for. A path that is both (staged half tracked,
/// say) prefers the real patch.
fn untracked_focus<'a>(snap: &DiffSnapshot, focus: Option<&'a str>) -> Option<&'a str> {
let path = focus?;
if snap.files.iter().any(|f| f.path == path) {
return None;
}
snap.untracked.iter().any(|u| u == path).then_some(path)
}
fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
let DiffLoad::Ready(snap) = &overlay.load else {
return None;
@@ -1740,6 +1884,23 @@ mod tests {
);
}
#[test]
fn a_focused_untracked_file_asks_for_a_preview_not_the_list() {
let snap = DiffSnapshot {
files: vec![small_file("tracked.rs", 3)],
untracked: vec!["new.md".to_string()],
..Default::default()
};
assert_eq!(untracked_focus(&snap, Some("new.md")), Some("new.md"));
assert_eq!(
untracked_focus(&snap, Some("tracked.rs")),
None,
"a real patch wins over the name list"
);
assert_eq!(untracked_focus(&snap, Some("absent.rs")), None);
assert_eq!(untracked_focus(&snap, None), None);
}
#[test]
fn untracked_rows_are_capped_but_the_count_stays_true() {
let snap = DiffSnapshot {