mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user