feat: show repository changes in normal file pickers

This commit is contained in:
2026-10-13 21:10:20 +02:00
parent e90d1c3945
commit 9526876b04
12 changed files with 2169 additions and 33 deletions
+5
View File
@@ -11,6 +11,11 @@
- new: green
- deleted: red
- changed: yellow
- [ ] mark changed files in normal file picker too (jj/git).
- [ ] git gutter flashes on every keystroke/change; this should be fixed.
- [ ] make all buffers (including pickers) scrolls the window when the cursor reaches a certain threshold. setting could something like "distance from frame edges", and the value could be an integer or none:
- "none" would mean "always keep the cursor in the center of the frame" (i.e. scroll the viewport whenever the cursor moves to a different line)
- an integer value would be the distance from the edge of the viewport that the cursor must hit before the viewport begins to scroll. a value of 0 means the viewport scrolls only right before the cursor would otherwise have left the viewport; a value greater than half the width of the viewport behaves similarly to "none" (at least until the viewport is resized to support that range in a meaningful way).
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
- github pr picker (floating window)
- three tabs: open, closed, all
+4 -2
View File
@@ -13,7 +13,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
@@ -53,7 +53,7 @@ Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached r
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
@@ -88,6 +88,8 @@ The buffer viewport includes a fixed left gutter with right-aligned hybrid line
Repository gutter work runs off the render/editor thread. Revision/path-validated marker vectors belong to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
## Syntax caches
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
+1 -1
View File
@@ -49,7 +49,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` disables repository indicators, not line numbers. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files.
+3
View File
@@ -57,6 +57,8 @@ Diff checks cover staged category/filter navigation, stale request cancellation,
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
@@ -71,5 +73,6 @@ Diff styling checks cover independent snapshot language/shebang/injection detect
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops.
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
+2
View File
@@ -305,6 +305,8 @@ The picker includes ordinary buffers from every tab and unnamed buffers as `[No
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available.
The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it.
+1
View File
@@ -185,6 +185,7 @@ pub(crate) fn run_status(
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_NO_LAZY_FETCH", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
+26 -1
View File
@@ -1351,6 +1351,7 @@ pub struct DocumentInfo<'a> {
pub path: Option<&'a Path>,
pub lines: &'a [String],
pub save_revision: u64,
pub dirty: bool,
}
struct AnnotationSet {
@@ -1674,6 +1675,7 @@ impl Buffer {
.or(self.path.as_deref()),
lines: &self.lines,
save_revision: self.save_revision,
dirty: self.dirty(),
}
}
@@ -4693,6 +4695,29 @@ impl Editor {
}
}
pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> {
self.file_picker.as_ref().filter(|_| self.diff.is_none())
}
pub(crate) fn set_picker_repository_changes(
&mut self,
tab: usize,
root: &Path,
changes: Vec<crate::repository::WorkingFileChange>,
) -> bool {
let picker = if tab == self.tab_id {
self.file_picker.as_mut().filter(|_| self.diff.is_none())
} else {
self.tabs
.get_mut(&tab)
.and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none()))
};
let Some(picker) = picker.filter(|picker| picker.root == root) else {
return false;
};
picker.set_repository_changes(changes)
}
pub fn file_explorer_side(&self) -> DockSide {
self.file_explorer_side
}
@@ -10485,7 +10510,7 @@ fn lsp_navigation_position(
Ok(TextPosition { row, column })
}
fn path_identity(path: &Path) -> io::Result<PathBuf> {
pub(crate) fn path_identity(path: &Path) -> io::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_owned()
} else {
+396 -1
View File
@@ -1,3 +1,5 @@
use crate::diff::FileStatus;
use crate::repository::WorkingFileChange;
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -39,6 +41,8 @@ pub struct FileRow<'a> {
pub label: &'a str,
/// Original virtual-catalog input index; absent for scanned entries and directories.
pub catalog_index: Option<usize>,
pub status: Option<FileStatus>,
pub old_path: Option<&'a Path>,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
@@ -51,6 +55,9 @@ struct Node {
search_label: String,
catalog_index: Option<usize>,
is_dir: bool,
status: Option<FileStatus>,
old_path: Option<PathBuf>,
virtual_entry: bool,
parent: Option<usize>,
children: Vec<usize>,
}
@@ -92,6 +99,9 @@ pub struct FilePicker {
filtered_selection: [Option<usize>; 2],
tree_searching: bool,
warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
has_repository_changes: bool,
has_virtual_entries: bool,
}
fn label(path: &Path) -> String {
@@ -101,6 +111,28 @@ fn label(path: &Path) -> String {
.collect()
}
fn safe_repository_path(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& path.components().all(|component| {
matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj")
})
}
fn missing_repository_path(root: &Path, path: &Path) -> bool {
// A deleted leaf may be absent with all its parents, but never walk through symlinks.
let mut current = root.to_owned();
for component in path.components() {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return false,
Err(error) if error.kind() == io::ErrorKind::NotFound => return true,
Err(_) => return false,
}
}
false
}
impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() {
@@ -156,6 +188,9 @@ impl FilePicker {
search_label: label.to_lowercase(),
label,
catalog_index: None,
status: None,
old_path: None,
virtual_entry: false,
is_dir: entry.file_type().unwrap().is_dir(),
parent: None,
children: Vec::new(),
@@ -186,6 +221,9 @@ impl FilePicker {
search_label: label.to_lowercase(),
label,
catalog_index,
status: None,
old_path: None,
virtual_entry: false,
is_dir,
parent: None,
children: Vec::new(),
@@ -255,6 +293,7 @@ impl FilePicker {
focused: true,
error: warning.clone(),
tree_for_node: vec![0; nodes.len()],
has_virtual_entries: nodes.iter().any(|node| node.virtual_entry),
nodes,
tree: Vec::new(),
tree_roots: Vec::new(),
@@ -265,6 +304,8 @@ impl FilePicker {
filtered_selection: [None, None],
tree_searching: false,
warning,
repository_changes: Vec::new(),
has_repository_changes: false,
};
for root in roots {
let tree = picker.compact(root, None);
@@ -315,6 +356,8 @@ impl FilePicker {
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir,
catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.as_deref(),
depth: row.depth,
expanded: row.expanded,
}
@@ -721,11 +764,158 @@ impl FilePicker {
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(replacement) => self.replace_catalog(replacement),
Ok(mut replacement) => {
replacement.set_repository_changes(self.repository_changes.clone());
self.replace_catalog(replacement);
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
pub(crate) fn has_repository_changes(&self) -> bool {
self.has_repository_changes
}
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
if !self.has_virtual_entries && changes == self.repository_changes {
return false;
}
if self.nodes.iter().any(|node| {
node.virtual_entry
&& !node.is_dir
&& fs::symlink_metadata(self.root.join(&node.path))
.is_ok_and(|metadata| metadata.is_file())
}) {
// Reuse the scanner's ignore/symlink rules when a deleted file returns.
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(mut replacement) => {
replacement.set_repository_changes(changes);
replacement.error = self.error.clone().or(replacement.error);
self.replace_catalog(replacement);
return true;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
let changes: Vec<_> = changes
.into_iter()
.filter(|change| safe_repository_path(&change.path))
.map(|mut change| {
change.old_path = change.old_path.filter(|path| safe_repository_path(path));
change
})
.collect();
let by_path: HashMap<_, _> = changes
.iter()
.map(|change| (change.path.as_path(), change))
.collect();
let scanned: HashSet<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| (node.path.as_path(), node.is_dir))
.collect();
let mut virtual_files = HashSet::new();
let mut virtual_dirs = HashSet::new();
for change in &changes {
if change.status != FileStatus::Removed
|| scanned.contains(&(change.path.as_path(), false))
|| !missing_repository_path(&self.root, &change.path)
{
continue;
}
virtual_files.insert(change.path.clone());
for parent in change.path.ancestors().skip(1) {
if parent.as_os_str().is_empty() {
break;
}
if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) {
virtual_dirs.insert(parent.to_owned());
}
}
}
let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count()
!= virtual_files.len() + virtual_dirs.len()
|| self
.nodes
.iter()
.filter(|node| node.virtual_entry)
.any(|node| {
!(if node.is_dir {
&virtual_dirs
} else {
&virtual_files
})
.contains(node.path.as_path())
});
let mut metadata_changed = false;
for node in &mut self.nodes {
let change = (!node.is_dir && node.catalog_index.is_none())
.then(|| by_path.get(node.path.as_path()))
.flatten();
let status = change.map(|change| change.status);
let old_path = change.and_then(|change| change.old_path.as_ref());
metadata_changed |= node.status != status || node.old_path.as_ref() != old_path;
node.status = status;
if node.old_path.as_ref() != old_path {
node.old_path = old_path.cloned();
}
}
if topology_changed {
let mut nodes: Vec<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| Node {
path: node.path.clone(),
label: node.label.clone(),
search_label: node.search_label.clone(),
catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.clone(),
virtual_entry: false,
is_dir: node.is_dir,
parent: None,
children: Vec::new(),
})
.collect();
nodes.extend(
virtual_files
.into_iter()
.map(|path| (path, false))
.chain(virtual_dirs.into_iter().map(|path| (path, true)))
.map(|(path, is_dir)| {
let label = label(&path);
let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten();
Node {
status: change.map(|change| change.status),
old_path: change.and_then(|change| change.old_path.clone()),
path,
search_label: label.to_lowercase(),
label,
catalog_index: None,
virtual_entry: true,
is_dir,
parent: None,
children: Vec::new(),
}
}),
);
let mut replacement = Self::from_nodes(
self.root.clone(),
nodes,
self.presentation,
self.view,
self.warning.clone(),
);
replacement.error = self.error.clone();
self.replace_catalog(replacement);
}
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
self.repository_changes = changes;
topology_changed || metadata_changed
}
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
}
@@ -835,6 +1025,7 @@ mod tests {
assert!(!rows.is_empty());
for row in rows {
assert_eq!(row.catalog_index, expected_index(&row));
assert!(row.status.is_none() && row.old_path.is_none());
}
}
}
@@ -881,9 +1072,213 @@ mod tests {
picker.set_view(view);
picker.reveal_path(Path::new("nested/file.txt"));
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
}
}
fn repository_change(
path: &str,
status: FileStatus,
old_path: Option<&str>,
) -> WorkingFileChange {
WorkingFileChange {
path: path.into(),
status,
old_path: old_path.map(PathBuf::from),
}
}
#[test]
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
let directory = Directory::new();
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
directory.put(name, "");
}
let mut picker = directory.scan();
picker.presentation = FilePresentation::Docked;
picker.set_view(FileView::Tree);
picker.reveal_path(Path::new("src/clean.rs"));
picker.query = "src".into();
picker.refilter();
picker.focused = false;
let changes = vec![
repository_change("src/modified.rs", FileStatus::Modified, None),
repository_change("added.rs", FileStatus::Added, None),
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
];
assert!(picker.set_repository_changes(changes.clone()));
assert!(!picker.set_repository_changes(changes));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert_eq!(picker.query, "src");
assert!(!picker.focused);
assert_eq!(picker.presentation, FilePresentation::Docked);
assert!(picker.has_repository_changes());
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "src", "modified.rs", "deleted.rs"] {
picker.query = query.into();
picker.refilter();
let rows: Vec<_> = picker.entries().collect();
assert!(!rows.is_empty());
for row in rows {
assert!(row.catalog_index.is_none());
if row.is_dir || row.path == Path::new("src/clean.rs") {
assert!(row.status.is_none() && row.old_path.is_none());
} else {
let status = match row.path.to_str().unwrap() {
"src/modified.rs" => FileStatus::Modified,
"added.rs" => FileStatus::Added,
"renamed.rs" => FileStatus::Renamed,
"vanished/deep/deleted.rs" => FileStatus::Removed,
other => panic!("unexpected {other}"),
};
assert_eq!(row.status, Some(status));
assert_eq!(
row.old_path,
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
);
}
}
if query.is_empty() || query == "src" {
assert!(
picker
.entries()
.any(|row| row.path == Path::new("src/clean.rs"))
);
}
}
}
picker.set_view(FileView::List);
picker.clear_filter();
picker.reveal_path(Path::new("src/clean.rs"));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert!(!picker.has_repository_changes());
assert_eq!(picker.entries().len(), 4);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
assert!(!picker.set_repository_changes(Vec::new()));
}
#[test]
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
let directory = Directory::new();
directory.put("keep.txt", "");
let mut picker = directory.scan();
let deleted = Path::new("lost/deep/deleted.txt");
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.set_view(FileView::Tree);
assert_eq!(
picker.entries().find(|row| row.is_dir).unwrap().label,
"lost/deep"
);
picker.reveal_path(deleted);
picker.query = "deleted".into();
picker.refilter();
picker.start_search();
picker.focused = false;
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.searching && !picker.focused);
assert_eq!(picker.query, "deleted");
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert!(!directory.0.join(deleted).exists());
assert_eq!(
picker.entries().find(|row| !row.is_dir).unwrap().status,
Some(FileStatus::Removed)
);
directory.put("lost/deep/deleted.txt", "restored");
assert!(picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]));
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), deleted);
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert_eq!(
fs::read_to_string(directory.0.join(deleted)).unwrap(),
"restored"
);
assert!(picker.entries().all(|row| row.status.is_none()));
fs::remove_dir_all(directory.0.join("lost")).unwrap();
picker.refresh();
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.reveal_path(deleted);
picker.set_repository_changes(Vec::new());
assert!(picker.entries().len() == 0);
picker.clear_filter();
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
}
#[test]
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
let directory = Directory::new();
directory.put(".gitignore", "ignored/\n");
directory.put("ignored/hidden.txt", "");
directory.put("directory/keep.txt", "");
let mut picker = directory.scan();
let mut changes: Vec<_> = [
"",
".",
"../outside.txt",
"directory/../../outside.txt",
".git/secret",
"nested/.jj/secret",
"/outside.txt",
"directory",
"ignored/hidden.txt",
]
.into_iter()
.map(|path| repository_change(path, FileStatus::Removed, None))
.collect();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
changes.push(repository_change(
"link/missing.txt",
FileStatus::Removed,
None,
));
}
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
picker.set_repository_changes(changes);
assert_eq!(
paths(&picker),
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
);
assert_eq!(
picker.entries().filter(|row| row.status.is_some()).count(),
1
);
assert_eq!(
picker.entries().last().unwrap().status,
Some(FileStatus::Removed)
);
assert!(!directory.0.join("missing.txt").exists());
}
#[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new();
+99 -11
View File
@@ -3,7 +3,9 @@ use crate::{
blame::{git, git_without_literal_pathspecs, run, run_status},
diff::line_hunks,
editor::normalize_newlines,
repository::{jj_command, jj_file_pattern, safe_path, store_for_jj},
repository::{
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
},
};
use serde::Deserialize;
use std::{
@@ -23,7 +25,13 @@ const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>;
type FileResult = Result<Vec<Option<Change>>, String>;
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
type Request = (u64, u64, Files);
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
type Request = (u64, u64, Files, Option<PickerRequest>);
pub(crate) struct PickerChanges {
pub(crate) root: PathBuf,
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -45,7 +53,7 @@ pub(crate) enum Change {
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultBatch)>,
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
}
#[derive(Default)]
struct Shared {
@@ -65,6 +73,7 @@ impl Worker {
let thread = thread::Builder::new()
.name("vamp-gutter".into())
.spawn(move || {
let mut picker_reader = WorkingFileReader::default();
loop {
let Ok(mut state) = worker.state.lock() else {
return;
@@ -78,7 +87,7 @@ impl Worker {
if worker.stopped.load(Ordering::Acquire) {
return;
}
let (generation, id, files) = state.request.take().unwrap();
let (generation, id, files, picker) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
@@ -91,11 +100,15 @@ impl Worker {
}
results.push((buffer, read_file(&path, &contents, &cancelled)));
}
let picker = picker.map(|(root, buffers)| PickerChanges {
result: picker_reader.read(&root, &buffers, &cancelled),
root,
});
let Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, results));
state.result = Some((id, results, picker));
}
}
})?;
@@ -105,12 +118,31 @@ impl Worker {
})
}
pub(crate) fn request(&mut self, id: u64, files: Files) -> io::Result<()> {
pub(crate) fn request(
&mut self,
id: u64,
files: Files,
picker: Option<PickerRequest>,
) -> io::Result<()> {
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
});
if files.len() > FILE_COUNT_LIMIT || size.is_none_or(|size| size > BATCH_LIMIT) {
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
buffers
.iter()
.try_fold(root.as_os_str().len(), |size, (path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
})
});
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
if count > FILE_COUNT_LIMIT
|| size
.zip(picker_size)
.and_then(|(files, picker)| files.checked_add(picker))
.is_none_or(|size| size > BATCH_LIMIT)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Gutter batch exceeds 64 MiB or 1024 files",
@@ -126,13 +158,13 @@ impl Worker {
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, files));
state.request = Some((generation, id, files, picker));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch)> {
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
self.shared.state.lock().ok()?.result.take()
}
}
@@ -766,11 +798,13 @@ mod tests {
(5, path.clone(), "first\nchanged\nthird\n".into()),
(8, path.clone(), Arc::clone(&oversized)),
],
None,
)
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
if let Some((id, result, picker)) = worker.poll() {
assert!(picker.is_none());
assert_eq!(id, 19);
assert_eq!(result[0].0, 5);
assert_eq!(
@@ -788,10 +822,64 @@ mod tests {
}
assert_eq!(
worker
.request(20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())])
.request(
20,
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
None
)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
let (temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
worker
.request(
1,
vec![],
Some((
temp.0.clone(),
vec![(path.clone(), "dirty buffer\n".into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 1);
assert!(lines.is_empty());
let picker = picker.unwrap();
assert_eq!(picker.root, temp.0);
let changes = picker.result.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
break;
}
thread::yield_now();
}
worker
.request(
2,
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
Some((
temp.0.clone(),
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 2);
assert_eq!(lines[0].0, 4);
assert!(lines[0].1.is_ok());
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
break;
}
thread::yield_now();
}
}
}
+1188
View File
File diff suppressed because it is too large Load Diff
+181 -9
View File
@@ -170,6 +170,7 @@ struct GutterSource {
revision: u64,
save_revision: u64,
path: PathBuf,
dirty: bool,
contents: Arc<str>,
}
@@ -198,6 +199,7 @@ pub struct EditorSession {
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
picker_source: Option<(usize, PathBuf)>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -312,6 +314,7 @@ impl EditorSession {
gutter_request_id: 0,
gutter_pending: false,
last_gutter_refresh: None,
picker_source: None,
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
@@ -356,6 +359,14 @@ impl EditorSession {
}
pub fn handle(&mut self, input: Input) {
if input == Input::Ctrl('r')
&& self
.editor
.normal_file_picker()
.is_some_and(|picker| picker.focused)
{
self.last_gutter_refresh = None;
}
self.cancel_pending_popup_request();
let invocation = self.editor.handle(input);
self.synchronize_documents();
@@ -1056,21 +1067,36 @@ impl EditorSession {
}
fn poll_gutter_at(&mut self, now: Instant) -> bool {
if self.editor.should_quit()
|| self.editor.gutter_style() == crate::gutter::GutterStyle::None
let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None;
let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) {
(Some(picker), Some((tab, root))) => {
*tab != self.editor.tab_id() || *root != picker.root
}
(None, None) => false,
_ => true,
};
if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none())
{
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
self.picker_source = None;
return false;
}
let sources_changed = !self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
document
.path
.map(|path| (document.id, document.revision, document.save_revision, path))
document.path.map(|path| {
(
document.id,
document.revision,
document.save_revision,
path,
document.dirty,
)
})
})
.eq(self.gutter_sources.iter().map(|source| {
(
@@ -1078,17 +1104,25 @@ impl EditorSession {
source.revision,
source.save_revision,
source.path.as_path(),
source.dirty,
)
}));
let refresh_due = !self.gutter_pending
&& self
.last_gutter_refresh
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
if sources_changed || refresh_due {
if sources_changed || picker_changed || refresh_due {
if picker_changed {
self.picker_source = self
.editor
.normal_file_picker()
.map(|picker| (self.editor.tab_id(), picker.root.clone()));
}
if sources_changed {
self.gutter_sources = self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
let path = document.path?;
let contents = self
@@ -1108,6 +1142,7 @@ impl EditorSession {
revision: document.revision,
save_revision: document.save_revision,
path: path.to_path_buf(),
dirty: document.dirty,
contents,
})
})
@@ -1115,7 +1150,7 @@ impl EditorSession {
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
if self.gutter_sources.is_empty() {
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
self.gutter_worker = None;
self.gutter_pending = false;
return false;
@@ -1132,6 +1167,7 @@ impl EditorSession {
let files = self
.gutter_sources
.iter()
.filter(|_| line_gutter)
.map(|source| {
(
source.buffer,
@@ -1140,11 +1176,20 @@ impl EditorSession {
)
})
.collect();
let picker = self.picker_source.as_ref().map(|(_, root)| {
let buffers = self
.gutter_sources
.iter()
.filter(|source| source.dirty)
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
.collect();
(root.clone(), buffers)
});
match self
.gutter_worker
.as_mut()
.expect("gutter worker started")
.request(self.gutter_request_id, files)
.request(self.gutter_request_id, files, picker)
{
Ok(()) => self.gutter_pending = true,
Err(error) => {
@@ -1156,12 +1201,15 @@ impl EditorSession {
}
}
let mut changed = false;
while let Some((id, result)) = self
while let Some((id, result, picker)) = self
.gutter_worker
.as_mut()
.and_then(crate::gutter::Worker::poll)
{
changed |= self.apply_gutter_result(id, result);
if let Some(picker) = picker {
changed |= self.apply_picker_result(id, picker);
}
}
changed
}
@@ -1195,6 +1243,36 @@ impl EditorSession {
changed
}
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
let Some((tab, root)) = &self.picker_source else {
return false;
};
if id != self.gutter_request_id
|| *tab != self.editor.tab_id()
|| *root != reply.root
|| self
.editor
.normal_file_picker()
.is_none_or(|picker| picker.root != *root)
{
return false;
}
let tab = *tab;
let mut changed = false;
let changes = match reply.result {
Ok(changes) => changes,
Err(error) => {
self.editor.set_message(format!("File status: {error}"));
changed = true;
Vec::new()
}
};
changed
| self
.editor
.set_picker_repository_changes(tab, &reply.root, changes)
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
self.last_gutter_refresh = None;
self.poll_gutter_at(Instant::now())
@@ -1399,6 +1477,100 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
use crate::diff::FileStatus;
let fixture = Fixture::new();
fixture.file("buffer.txt", "base\n");
fixture.file("clean.txt", "clean\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q", "--template="]);
git(&["add", "buffer.txt", "clean.txt"]);
git(&["commit", "-qm", "baseline"]);
fixture.file("buffer.txt", "disk\n");
fixture.file("added.txt", "new\n");
let mut session = fixture.open(
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
);
session.editor.set_project_root(fixture.0.clone());
let now = session.last_file_refresh;
let open = |session: &mut EditorSession| {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(clock);
let picker = session.editor.normal_file_picker().unwrap();
let status = picker
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.expect("normal picker retained the buffer file")
.status;
if !session.gutter_pending && status == expected {
assert!(
picker.entries().any(|row| {
row.path == Path::new("clean.txt") && row.status.is_none()
})
);
assert!(picker.entries().any(|row| {
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
}));
break;
}
assert!(
Instant::now() < deadline,
"normal picker status did not settle: {}",
session.editor.message()
);
thread::sleep(Duration::from_millis(5));
}
};
open(&mut session);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
open(&mut session);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
open(&mut session);
ready(&mut session, now, Some(FileStatus::Modified));
git(&["add", "buffer.txt"]);
git(&["commit", "-qm", "new baseline"]);
ready(&mut session, now + Duration::from_secs(1), None);
}
#[test]
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
use crate::gutter::Change;
+263 -8
View File
@@ -4269,6 +4269,7 @@ fn draw_file_list(
);
}
let entries = picker.entries();
let normal_markers = changed_files.is_none() && picker.has_repository_changes();
if entries.len() == 0 {
frame.render_widget(
Paragraph::new(if picker.query.is_empty() {
@@ -4304,6 +4305,10 @@ fn draw_file_list(
let changed = entry
.catalog_index
.and_then(|index| changed_files.and_then(|(files, _)| files.get(index)));
let status = changed.map(|file| file.status).or(entry.status);
let old_path = changed
.and_then(|file| file.old_path.as_deref())
.or(entry.old_path);
let prefix = format!(
"{} {:indent$}",
if selected { ">" } else { " " },
@@ -4314,9 +4319,9 @@ fn draw_file_list(
0
},
);
let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 });
let mut spans = Vec::with_capacity(if status.is_some() { 7 } else { 3 });
spans.push(Span::raw(prefix));
if let Some(file) = changed {
if changed.is_some() {
let reviewed = entry.catalog_index.is_some_and(|index| {
changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index))
});
@@ -4324,25 +4329,32 @@ fn draw_file_list(
if reviewed { "✓ " } else { " " },
Style::default().fg(Color::Green),
));
let color = match file.status {
}
if let Some(status) = status {
let color = match status {
FileStatus::Added => Color::Green,
FileStatus::Removed => Color::Red,
FileStatus::Modified => Color::Yellow,
FileStatus::Renamed => Color::Cyan,
};
spans.push(Span::styled(
diff_status(file.status),
diff_status(status),
Style::default().fg(color),
));
spans.push(Span::raw(" "));
} else if normal_markers {
spans.push(Span::raw(" "));
}
if let Some(file) = changed
&& file.status == FileStatus::Renamed
&& let Some(old_path) = &file.old_path
if status == Some(FileStatus::Renamed)
&& let Some(old_path) = old_path
{
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
spans.push(Span::raw(" -> "));
spans.push(Span::raw(display_text(&file.path.to_string_lossy())));
spans.push(Span::raw(display_text(
&changed
.map_or(entry.path, |file| file.path.as_path())
.to_string_lossy(),
)));
} else {
spans.push(Span::raw(entry.label));
if entry.is_dir && picker.view == FileView::Tree {
@@ -5956,6 +5968,249 @@ mod tests {
}
}
#[test]
fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() {
use crate::repository::WorkingFileChange;
let directory = Directory::new();
let changes: Vec<_> = [
("z.txt", FileStatus::Added),
("chain/a/m.txt", FileStatus::Modified),
("gone/deep/d.txt", FileStatus::Removed),
("r.txt", FileStatus::Renamed),
]
.into_iter()
.map(|(path, status)| {
if status != FileStatus::Removed {
directory.file(path, "");
}
WorkingFileChange {
path: path.into(),
status,
old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()),
}
})
.collect();
directory.file("clean.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
picker.set_repository_changes(changes.clone());
for change in &changes {
picker.reveal_path(&change.path);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
picker.presentation = presentation;
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
for focused in [true, false] {
picker.focused = focused;
for selected in changes
.iter()
.map(|change| change.path.as_path())
.chain(std::iter::once(std::path::Path::new("clean.txt")))
{
picker.reveal_path(selected);
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("clean.txt"));
assert!(!text.contains('✓'));
assert!(!text.contains("old\nname.txt"));
let buffer = terminal.backend().buffer();
for (change, color) in changes.iter().zip([
Color::Green,
Color::Yellow,
Color::Red,
Color::Cyan,
]) {
let path = if view == FileView::Tree {
std::path::Path::new(change.path.file_name().unwrap())
} else {
change.path.as_path()
};
let expected = if change.status == FileStatus::Renamed {
"R old�name.txt -> r.txt".to_owned()
} else {
format!("{} {}", diff_status(change.status), path.to_string_lossy())
};
let (x, y) = (0..24)
.find_map(|y| {
let row = (0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>();
row.find(&expected).map(|offset| {
(UnicodeWidthStr::width(&row[..offset]) as u16, y)
})
})
.unwrap_or_else(|| panic!("missing {expected}"));
assert_eq!(buffer[(x, y)].fg, color);
assert_eq!(
buffer[(x, y)].bg,
if change.path == selected {
if focused {
Color::Blue
} else {
Color::DarkGray
}
} else {
Color::Reset
}
);
}
let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap();
assert!(clean_row.contains(
if selected == std::path::Path::new("clean.txt") {
"> clean.txt"
} else {
" clean.txt"
}
));
}
for query in ["m.txt", "clean"] {
picker.query = query.into();
picker.refilter();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
if query == "m.txt" {
assert!(text.contains("M "));
assert!(!text.contains("clean.txt"));
} else {
assert!(text.contains("> clean.txt"));
assert!(!text.contains("M "));
}
assert!(!text.contains('✓'));
picker.clear_filter();
}
}
}
}
picker.refresh();
assert!(picker.has_repository_changes());
assert!(
picker
.entries()
.any(|row| row.status == Some(FileStatus::Renamed))
);
picker.set_repository_changes(Vec::new());
picker.set_view(FileView::List);
picker.reveal_path(std::path::Path::new("clean.txt"));
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("> clean.txt"));
assert!(text.contains("r.txt"));
assert!(!text.contains("old�name.txt"));
assert!(!text.contains("d.txt"));
assert!(!text.contains('✓'));
}
#[test]
fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() {
let directory = Directory::new();
directory.file("界.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
picker.set_repository_changes(vec![crate::repository::WorkingFileChange {
path: "界.txt".into(),
status: FileStatus::Modified,
old_path: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap();
for focused in [true, false] {
picker.focused = focused;
for height in 0..=3 {
for width in 0..=8 {
terminal
.draw(|frame| {
frame.render_widget(
Paragraph::new(
"XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX",
),
frame.area(),
);
draw_file_list(
frame,
&picker,
Rect::new(0, 0, width, height),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..4 {
for x in 0..12 {
if x >= width || y >= height {
assert_eq!(buffer[(x, y)].symbol(), "X");
}
}
}
if height < 2 {
continue;
}
for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() {
assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string());
}
if width >= 3 {
assert_eq!(buffer[(2, 1)].fg, Color::Yellow);
assert_eq!(
buffer[(2, 1)].bg,
if focused {
Color::Blue
} else {
Color::DarkGray
}
);
}
if width >= 6 {
assert_eq!(buffer[(4, 1)].symbol(), "界");
}
}
}
}
}
#[test]
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
let mut diff = diff_comparison(vec![deletion_file()]);