Files
tty7/src/ui/diff_list.rs
T
l0ng-ai ae43c67f40 Merge origin/main into feat/copy-from-diff-view
#799 landed first and rebuilt the overlay as one virtualised `gpui::list`
of flat rows, deleting the card, the hunk nesting and the three row
builders this branch had hung the drag on. The selection is re-applied
onto that model rather than rebuilt beside it.

**Where a row is.** `DiffRow::Split`/`Unified` now carry a `RowAt` — the
file's path and the `RowId { hunk, row }` the range is keyed on. The
list index is deliberately *not* that key: `resync_list` splices only
the rows between a shared prefix and suffix, so collapsing a file above
the selection moves every index below it, and a range keyed on those
would go on naming the same slots while the code under them changed.
`RowAt::path` is a `SharedString` so one rides on every line of a patch
for a refcount bump.

**Where the selection is read.** `diff_rows_list` clones it into a
`Drag` once a frame and the list's item builder reads that. It stays out
of `RowsKey` on purpose: a selection changes what a row looks like, not
which rows there are, so a step of a drag notifies, re-renders the rows
on screen, and never re-flattens the patch — `gpui::list` calls its item
builder for every visible row on each layout pass, so the highlight is
current without the key knowing anything about it.

**Where the menu is.** `diff_file_card` was the old host and is gone.
Copy Selected Lines now hangs on the selected rows themselves. The
overlay root was the other candidate and is not usable: the header
already carries `document_header_menu`, and gpui-component's context
menu opens on a bubbling right-press without stopping propagation, so
two of them over one click would open two popups.

The padding-cell fix from 87dc26f0..f7fd1d43 is carried across: a blank
half of a split row takes the same press and move listeners as any other
cell, and the selection colour when the range covers it.

Also drops `HunkRows::len`/`is_empty`, which only the deleted card body
called.

Tests: the drag suite moves to `selection_gpui_tests` and drives the
same cases against the new state, including the upward drag; two new
`diff_list` tests pin the coordinates a row carries — that they name the
patch rather than the list, and that they survive a collapse above them.
2026-09-07 22:40:07 +08:00

576 lines
19 KiB
Rust

//! Flattening a diff snapshot into the one-dimensional list of rows the
//! overlay scrolls.
//!
//! The overlay used to build its whole patch as a nested element tree —
//! a card per file, a hunk header and one element per line inside it, every
//! one of them constructed on every frame. A `20_000` line budget is around
//! `120_000` elements to lay out, and gpui notifies the view on each scroll
//! wheel event, so a diff of any size rebuilt the entire tree tens of times a
//! second.
//!
//! [`gpui::list`] only builds the rows it can see, but it is one-dimensional:
//! it takes an index, not a tree. So the tree is flattened here, once per
//! change to what is on screen.
use std::collections::HashMap;
use gpui::SharedString;
use crate::core::config::DiffViewMode;
use crate::terminal::git_diff::{
AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, MAX_RENDERED_FILES, Truncation,
};
use crate::ui::diff_rows::{RowId, SplitRow, UnifiedRow, split_hunk, unified_rows};
/// Everything the file header row draws, lifted out of its [`FileDiff`].
///
/// A copy rather than a borrow: the row list outlives the frame that built it,
/// and these are a handful of small fields against a file's whole patch.
#[derive(PartialEq, Eq)]
pub(crate) struct FileHead {
/// Position in `snap.files`, for the element id and nothing else.
pub(crate) index: usize,
pub(crate) path: String,
/// `old → new` for a rename, the path itself otherwise.
pub(crate) shown_path: String,
pub(crate) status: FileStatus,
pub(crate) added: u32,
pub(crate) removed: u32,
pub(crate) binary: bool,
pub(crate) expandable: bool,
pub(crate) expanded: bool,
}
/// Where a drawn line sits in the patch: which file's rows it belongs to, and
/// its place among them.
///
/// Carried on the row rather than read off its position in the list. The list
/// is spliced — collapsing a file above this one moves every index below it —
/// so a range keyed on list positions would go on naming the same slots while
/// the code under them changed.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct RowAt {
/// One of these rides on every line of a patch, so the path is shared
/// rather than copied per row.
pub(crate) path: SharedString,
pub(crate) id: RowId,
}
#[derive(PartialEq, Eq)]
pub(crate) enum DiffRow {
/// The space that used to be the `gap_3` of a flex column.
Gap,
/// The banner above an auto-collapsed tree. Its text is derived from the
/// snapshot at render time, so nothing is carried here.
Oversized,
FileHeader(FileHead),
HunkHeader {
text: String,
/// The first hunk of a file follows its header and needs no rule
/// above it; every later one is separating itself from the lines of
/// the hunk before.
leads: bool,
},
Split {
row: SplitRow,
at: RowAt,
},
Unified {
row: UnifiedRow,
at: RowAt,
},
Truncated(Truncation),
MoreFiles {
rest: usize,
},
UntrackedHeader {
total: usize,
},
Untracked {
index: usize,
path: String,
},
MoreUntracked {
rest: usize,
},
}
/// The rows of the whole overlay, in the order they scroll past.
pub(crate) fn build_rows(
snap: &DiffSnapshot,
expanded: &HashMap<String, bool>,
focused: Option<usize>,
mode: DiffViewMode,
oversized: bool,
) -> Vec<DiffRow> {
let mut out: Vec<DiffRow> = Vec::new();
// Stands in for the gap between the groups this list used to be a flex
// column of: gpui's list stacks its items with nothing between them.
let gap = |out: &mut Vec<DiffRow>| {
if !out.is_empty() {
out.push(DiffRow::Gap);
}
};
if oversized {
out.push(DiffRow::Oversized);
}
let shown = snap.files.len().min(MAX_RENDERED_FILES);
for (idx, file) in snap.files.iter().enumerate() {
if focused.is_some_and(|f| f != idx) {
continue;
}
if focused.is_none() && idx >= shown {
break;
}
let is_expanded = if focused == Some(idx) {
expanded.get(&file.path).copied().unwrap_or(true)
} else {
file_expanded(file, expanded, oversized)
};
gap(&mut out);
out.extend(file_rows(idx, file, is_expanded, mode));
}
if focused.is_none() && snap.files.len() > shown {
gap(&mut out);
out.push(DiffRow::MoreFiles {
rest: snap.files.len() - shown,
});
}
if focused.is_none() && !snap.untracked.is_empty() {
gap(&mut out);
out.extend(untracked_rows(snap));
}
out
}
/// The single synthesized file an untracked file's preview shows.
///
/// `usize::MAX` keeps its element ids clear of the real list's.
pub(crate) fn preview_rows(file: &FileDiff, mode: DiffViewMode) -> Vec<DiffRow> {
file_rows(usize::MAX, file, true, mode)
}
fn file_rows(index: usize, file: &FileDiff, expanded: bool, mode: DiffViewMode) -> Vec<DiffRow> {
let expandable =
!file.binary && (!file.hunks.is_empty() || file.truncated == Some(Truncation::Budget));
let shown_path = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let mut rows = vec![DiffRow::FileHeader(FileHead {
index,
path: file.path.clone(),
shown_path,
status: file.status,
added: file.added,
removed: file.removed,
binary: file.binary,
expandable,
expanded,
})];
if expanded && (!file.hunks.is_empty() || file.truncated.is_some()) {
let path = SharedString::from(file.path.clone());
for (h, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::HunkHeader {
text: hunk.header.clone(),
leads: h == 0,
});
let at = |row| RowAt {
path: path.clone(),
id: RowId { hunk: h, row },
};
match mode {
DiffViewMode::Split => rows.extend(
split_hunk(&hunk.lines)
.into_iter()
.enumerate()
.map(|(r, row)| DiffRow::Split { row, at: at(r) }),
),
DiffViewMode::Unified => rows.extend(
unified_rows(&hunk.lines)
.into_iter()
.enumerate()
.map(|(r, row)| DiffRow::Unified { row, at: at(r) }),
),
}
}
if let Some(reason) = file.truncated {
rows.push(DiffRow::Truncated(reason));
}
}
rows
}
fn untracked_rows(snap: &DiffSnapshot) -> Vec<DiffRow> {
let total = snap.untracked_count();
let shown = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)];
let mut rows = vec![DiffRow::UntrackedHeader { total }];
for (index, path) in shown.iter().enumerate() {
rows.push(DiffRow::Untracked {
index,
path: path.clone(),
});
}
if total > shown.len() {
rows.push(DiffRow::MoreUntracked {
rest: total - shown.len(),
});
}
rows
}
/// Whether a file's lines show on their own, absent an explicit choice.
pub(crate) 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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::git_diff::{DiffLine, Hunk, LineKind};
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>) -> DiffLine {
DiffLine {
kind,
old_no: old,
new_no: new,
text: "x".to_string(),
}
}
/// One removal answered by one addition, wrapped in a context line either
/// side — four lines unified, three rows split.
fn hunk() -> Hunk {
Hunk {
header: "@@ -1,3 +1,3 @@".to_string(),
lines: vec![
line(LineKind::Context, Some(1), Some(1)),
line(LineKind::Removed, Some(2), None),
line(LineKind::Added, None, Some(2)),
line(LineKind::Context, Some(3), Some(3)),
],
}
}
fn file(path: &str, hunks: usize) -> FileDiff {
FileDiff {
path: path.to_string(),
old_path: None,
status: FileStatus::Modified,
added: 1,
removed: 1,
binary: false,
truncated: None,
hunks: std::iter::repeat_n(hunk(), hunks).collect(),
}
}
fn snapshot(files: Vec<FileDiff>) -> DiffSnapshot {
DiffSnapshot {
files,
..Default::default()
}
}
/// `(path, hunk, row)` for each line row, in list order.
fn line_coords(rows: &[DiffRow]) -> Vec<(String, usize, usize)> {
rows.iter()
.filter_map(|row| match row {
DiffRow::Split { at, .. } | DiffRow::Unified { at, .. } => {
Some((at.path.to_string(), at.id.hunk, at.id.row))
}
_ => None,
})
.collect()
}
/// A row says where it is in the *patch*, not where it is in the list.
/// Collapsing a file splices the rows below it up the list; a range keyed
/// on list positions would stay where it was and come away naming other
/// code.
#[test]
fn a_line_row_names_its_file_and_its_place_in_the_hunk() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let open = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
let coords = line_coords(&open);
assert_eq!(
coords,
vec![
("a.rs".to_string(), 0, 0),
("a.rs".to_string(), 0, 1),
("a.rs".to_string(), 0, 2),
("a.rs".to_string(), 0, 3),
("b.rs".to_string(), 0, 0),
("b.rs".to_string(), 0, 1),
("b.rs".to_string(), 0, 2),
("b.rs".to_string(), 0, 3),
]
);
let collapsed = HashMap::from([("a.rs".to_string(), false)]);
let after = build_rows(&snap, &collapsed, None, DiffViewMode::Unified, false);
assert_eq!(
line_coords(&after),
coords[4..].to_vec(),
"b.rs's lines moved up the list and kept the coordinates they had"
);
}
/// Every hunk restarts the row count, so a range that spans two of them is
/// read hunk by hunk rather than as one run.
#[test]
fn each_hunk_numbers_its_own_rows() {
let snap = snapshot(vec![file("a.rs", 2)]);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Split, false);
let hunks: Vec<_> = line_coords(&rows)
.into_iter()
.map(|(_, hunk, row)| (hunk, row))
.collect();
assert_eq!(hunks, vec![(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]);
}
fn shape(rows: &[DiffRow]) -> Vec<&'static str> {
rows.iter()
.map(|row| match row {
DiffRow::Gap => "gap",
DiffRow::Oversized => "oversized",
DiffRow::FileHeader(_) => "file",
DiffRow::HunkHeader { .. } => "hunk",
DiffRow::Split { .. } => "split",
DiffRow::Unified { .. } => "unified",
DiffRow::Truncated(_) => "truncated",
DiffRow::MoreFiles { .. } => "more-files",
DiffRow::UntrackedHeader { .. } => "untracked-header",
DiffRow::Untracked { .. } => "untracked",
DiffRow::MoreUntracked { .. } => "more-untracked",
})
.collect()
}
fn open(paths: [&str; 1]) -> HashMap<String, bool> {
paths.into_iter().map(|p| (p.to_string(), true)).collect()
}
#[test]
fn an_open_file_flattens_to_a_header_a_hunk_header_and_a_row_per_line() {
let snap = snapshot(vec![file("a.rs", 1)]);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Split, false);
assert_eq!(
shape(&rows),
["file", "hunk", "split", "split", "split"],
"the split view pairs the removal with the addition that replaced it"
);
}
#[test]
fn only_the_first_hunk_of_a_file_follows_its_header() {
let rows = build_rows(
&snapshot(vec![file("a.rs", 2)]),
&HashMap::new(),
None,
DiffViewMode::Unified,
false,
);
let leads: Vec<bool> = rows
.iter()
.filter_map(|r| match r {
DiffRow::HunkHeader { leads, .. } => Some(*leads),
_ => None,
})
.collect();
assert_eq!(
leads,
[true, false],
"the second hunk draws the rule that separates it from the first"
);
}
#[test]
fn a_collapsed_file_is_a_single_row() {
let snap = snapshot(vec![file("a.rs", 1)]);
let shut: HashMap<String, bool> = [("a.rs".to_string(), false)].into_iter().collect();
let rows = build_rows(&snap, &shut, None, DiffViewMode::Unified, false);
assert_eq!(shape(&rows), ["file"]);
}
#[test]
fn a_truncation_note_lands_under_the_lines_it_is_about() {
let mut f = file("a.rs", 1);
f.truncated = Some(Truncation::Budget);
let rows = build_rows(
&snapshot(vec![f]),
&HashMap::new(),
None,
DiffViewMode::Unified,
false,
);
assert_eq!(*shape(&rows).last().unwrap(), "truncated");
}
#[test]
fn files_are_separated_by_a_gap_and_the_list_never_opens_with_one() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let shut: HashMap<String, bool> =
snap.files.iter().map(|f| (f.path.clone(), false)).collect();
let rows = build_rows(&snap, &shut, None, DiffViewMode::Unified, false);
assert_eq!(shape(&rows), ["file", "gap", "file"]);
}
#[test]
fn the_oversized_banner_leads_and_collapses_what_was_not_asked_for() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let rows = build_rows(&snap, &open(["b.rs"]), None, DiffViewMode::Unified, true);
assert_eq!(
shape(&rows),
[
"oversized",
"gap",
"file",
"gap",
"file",
"hunk",
"unified",
"unified",
"unified",
"unified"
],
"the file the reader opened stays open; the other one does not"
);
}
#[test]
fn a_focused_file_is_the_only_one_flattened() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let rows = build_rows(
&snap,
&HashMap::new(),
Some(1),
DiffViewMode::Unified,
false,
);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let DiffRow::FileHeader(head) = &rows[0] else {
panic!("the focused file leads");
};
assert_eq!(head.path, "b.rs");
assert_eq!(head.index, 1, "the element id still points into `files`");
}
#[test]
fn the_file_list_is_capped_and_the_tail_gets_one_line() {
let snap = snapshot(
(0..MAX_RENDERED_FILES + 25)
.map(|i| {
let mut f = file(&format!("f{i}.rs"), 1);
f.hunks.clear();
f
})
.collect(),
);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
let files = rows
.iter()
.filter(|r| matches!(r, DiffRow::FileHeader(_)))
.count();
assert_eq!(files, MAX_RENDERED_FILES);
assert!(matches!(rows.last(), Some(DiffRow::MoreFiles { rest: 25 })));
}
#[test]
fn the_untracked_list_is_capped_but_its_count_stays_true() {
let mut snap = snapshot(Vec::new());
snap.untracked = (0..MAX_RENDERED_FILES + 5)
.map(|i| format!("p{i}"))
.collect();
snap.untracked_total = 9_000;
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
assert!(matches!(
rows.first(),
Some(DiffRow::UntrackedHeader { total: 9_000 })
));
let shown = rows
.iter()
.filter(|r| matches!(r, DiffRow::Untracked { .. }))
.count();
assert_eq!(shown, MAX_RENDERED_FILES);
assert!(matches!(
rows.last(),
Some(DiffRow::MoreUntracked { rest }) if *rest == 9_000 - MAX_RENDERED_FILES
));
}
#[test]
fn a_preview_is_one_open_file() {
let rows = preview_rows(&file("new.md", 1), DiffViewMode::Unified);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let DiffRow::FileHeader(head) = &rows[0] else {
panic!("a file opens with its header");
};
assert_eq!(
head.index,
usize::MAX,
"its element ids stay clear of the real list's"
);
assert!(head.expanded);
}
#[test]
fn a_rename_shows_both_names_and_a_binary_file_cannot_be_opened() {
let mut renamed = file("new.rs", 1);
renamed.old_path = Some("old.rs".to_string());
let rows = preview_rows(&renamed, DiffViewMode::Unified);
let DiffRow::FileHeader(head) = &rows[0] else {
unreachable!()
};
assert_eq!(head.shown_path, "old.rs → new.rs");
assert_eq!(head.path, "new.rs", "the key is still the file itself");
let mut binary = file("logo.png", 0);
binary.binary = true;
let rows = preview_rows(&binary, DiffViewMode::Unified);
let DiffRow::FileHeader(head) = &rows[0] else {
unreachable!()
};
assert!(!head.expandable);
}
/// The threshold that decides whether a file opens on its own, kept where
/// the flattening that reads it lives.
#[test]
fn a_file_over_the_line_count_opens_only_when_asked() {
let mut big = file("big.rs", 1);
big.added = AUTO_COLLAPSE_LINES + 1;
let none = HashMap::new();
assert!(!file_expanded(&big, &none, false));
assert!(file_expanded(&big, &open(["big.rs"]), true));
assert!(file_expanded(&file("small.rs", 1), &none, false));
}
}