diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 953c7415..cb27a3a3 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -374,6 +374,55 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { 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 { 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. diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 5caf1658..aba3fa96 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -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, pub(crate) focus: Option, + /// 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>)>, + pub(crate) preview_loading: Option, 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, ) -> Option { + 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) { + 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, + ) -> 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) -> AnyElement { div() .flex_1() @@ -1208,6 +1341,17 @@ fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option(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 { 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 {