feat: toggle diff explorer and show file change indicators
This commit is contained in:
@@ -10,7 +10,8 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `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.
|
||||
- `diff`: owns searchable Before/After selection, category and focus state, request freshness, read-only comparison DTOs, and aligned rows/shared vertical and 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.
|
||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, and aligned rows/shared vertical and 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.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
||||
|
||||
@@ -137,6 +137,8 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
|
||||
The diff's right-docked changed-file explorer uses these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's diff rather than an editable buffer; its catalog comes from the comparison, not a disk scan.
|
||||
|
||||
In the diff view, Ctrl+e is reserved for hiding/showing the changed-file explorer and takes precedence over tree/selection remaps. Its query, selection, and expansion state survive hiding; the panes use the reclaimed width.
|
||||
|
||||
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
||||
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle.
|
||||
|
||||
@@ -48,6 +48,7 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
|
||||
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||
|
||||
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.
|
||||
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
|
||||
|
||||
## Performance and process limits
|
||||
|
||||
|
||||
@@ -211,6 +211,10 @@ Remote references such as `some-bookmark@origin` appear from remote advertisemen
|
||||
|
||||
The read-only comparison has Before on the left, After on the right, and the changed-file explorer **docked on the right**. The explorer reuses the normal compact directory tree, filtering, and configurable `tree`/`selection` navigation. Its catalog contains only changed paths, including removed and historical files; it does not scan the working directory. Use h/j/k/l or control/arrow navigation, Enter to toggle a folder or open a file, `i` to filter, Ctrl+u to clear, and Ctrl+t to switch Tree/List. Opening a file focuses Before.
|
||||
|
||||
**Ctrl+e** hides/shows the explorer in the diff view. Hiding reclaims its entire width for the split panes without losing the filter, selected row, folder expansion, displayed file, or pane scroll offsets. Hiding from Files moves focus to Before; showing restores Files focus. While hidden, Tab/Shift+Tab cycles only between Before and After. Ctrl+e is a reserved diff-view control and takes precedence over tree/selection remaps.
|
||||
|
||||
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
|
||||
|
||||
Added/deleted rows are aligned with blank counterparts. Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
|
||||
|
||||
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
||||
|
||||
+162
-3
@@ -148,6 +148,7 @@ pub struct DiffState {
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub files: Option<FilePicker>,
|
||||
pub files_visible: bool,
|
||||
pub file: usize,
|
||||
pub focus: Focus,
|
||||
pub top: usize,
|
||||
@@ -177,6 +178,7 @@ impl DiffState {
|
||||
error: None,
|
||||
comparison: None,
|
||||
files: None,
|
||||
files_visible: true,
|
||||
file: 0,
|
||||
focus: Focus::Files,
|
||||
top: 0,
|
||||
@@ -434,6 +436,18 @@ impl DiffState {
|
||||
}
|
||||
|
||||
fn handle_view(&mut self, input: Input) {
|
||||
if input == Input::Ctrl('e') {
|
||||
self.files_visible = !self.files_visible;
|
||||
if self.files_visible {
|
||||
self.focus = Focus::Files;
|
||||
} else if self.focus == Focus::Files {
|
||||
self.focus = Focus::Before;
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.focused = self.files_visible && self.focus == Focus::Files;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.focus == Focus::Files {
|
||||
let files = self.files.as_mut().expect("comparison file picker");
|
||||
match input {
|
||||
@@ -491,14 +505,16 @@ impl DiffState {
|
||||
self.focus = match self.focus {
|
||||
Focus::Files => Focus::Before,
|
||||
Focus::Before => Focus::After,
|
||||
Focus::After => Focus::Files,
|
||||
Focus::After if self.files_visible => Focus::Files,
|
||||
Focus::After => Focus::Before,
|
||||
}
|
||||
}
|
||||
Input::BackTab => {
|
||||
self.focus = match self.focus {
|
||||
Focus::Files => Focus::After,
|
||||
Focus::After => Focus::Before,
|
||||
Focus::Before => Focus::Files,
|
||||
Focus::Before if self.files_visible => Focus::Files,
|
||||
Focus::Before => Focus::After,
|
||||
}
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => self.navigate(false),
|
||||
@@ -527,7 +543,7 @@ impl DiffState {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.focused = self.focus == Focus::Files;
|
||||
files.focused = self.files_visible && self.focus == Focus::Files;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,6 +1024,149 @@ mod tests {
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explorer_toggle_preserves_filtered_tree_review_and_source_state() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("unsaved source".into())])
|
||||
.unwrap();
|
||||
let original = editor.context().text;
|
||||
let carets = editor.carets();
|
||||
let revision = editor.text_revision();
|
||||
open(&mut editor);
|
||||
let id = choose_pair(&mut editor);
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "src/deep/renamed.txt".into();
|
||||
changes.files[1].path = "src/deep/added.txt".into();
|
||||
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Char('i'));
|
||||
text(&mut editor, "src");
|
||||
editor.handle(Input::Down);
|
||||
let snapshot = |state: &DiffState| {
|
||||
let files = state.files.as_ref().unwrap();
|
||||
(
|
||||
files.query.clone(),
|
||||
files.selected,
|
||||
files.view,
|
||||
files.searching,
|
||||
files
|
||||
.entries()
|
||||
.map(|row| (row.path.to_owned(), row.expanded))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let tree = snapshot(editor.diff().unwrap());
|
||||
let rows = editor
|
||||
.diff()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| (row.before, row.after, row.kind))
|
||||
.collect::<Vec<_>>();
|
||||
editor
|
||||
.bind("tree", "ctrl+e", Some("core.down".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("selection", "ctrl+e", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let state = editor.diff().unwrap();
|
||||
assert!(!state.files_visible);
|
||||
assert_eq!(state.focus, Focus::Before);
|
||||
assert!(!state.files.as_ref().unwrap().focused);
|
||||
assert_eq!(
|
||||
(state.file, state.top, state.left_before, state.left_after),
|
||||
(0, 1, 1, 1)
|
||||
);
|
||||
assert_eq!(snapshot(state), tree);
|
||||
for (input, focus) in [
|
||||
(Input::Char('\t'), Focus::After),
|
||||
(Input::Char('\t'), Focus::Before),
|
||||
(Input::BackTab, Focus::After),
|
||||
(Input::BackTab, Focus::Before),
|
||||
] {
|
||||
editor.handle(input);
|
||||
let state = editor.diff().unwrap();
|
||||
assert_eq!(state.focus, focus);
|
||||
assert!(!state.files.as_ref().unwrap().focused);
|
||||
}
|
||||
for input in [
|
||||
Input::Char('z'),
|
||||
Input::Ctrl('t'),
|
||||
Input::Backspace,
|
||||
Input::Delete,
|
||||
Input::Paste("not source".into()),
|
||||
Input::Enter,
|
||||
] {
|
||||
editor.handle(input);
|
||||
}
|
||||
assert_eq!(snapshot(editor.diff().unwrap()), tree);
|
||||
editor.handle(Input::Down);
|
||||
assert_eq!(editor.diff().unwrap().top, 2);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let state = editor.diff().unwrap();
|
||||
assert!(state.files_visible && state.files.as_ref().unwrap().focused);
|
||||
assert_eq!(state.focus, Focus::Files);
|
||||
assert_eq!(snapshot(state), tree);
|
||||
assert_eq!(
|
||||
(state.file, state.top, state.left_before, state.left_after),
|
||||
(0, 2, 1, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| (row.before, row.after, row.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
rows
|
||||
);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
assert!(!editor.diff().unwrap().files.as_ref().unwrap().focused);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
|
||||
assert_eq!(snapshot(editor.diff().unwrap()), tree);
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explorer_toggle_is_not_a_revision_selector_control() {
|
||||
let mut editor = editor();
|
||||
open(&mut editor);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().selected, 0);
|
||||
editor
|
||||
.bind("selection", "ctrl+e", Some("core.down".into()))
|
||||
.unwrap();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(editor.diff().unwrap().phase, phase);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let state = editor.diff().unwrap();
|
||||
assert!(state.files_visible);
|
||||
assert_eq!(state.selected, 1);
|
||||
assert_eq!(state.phase, phase);
|
||||
editor.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
|
||||
let mut editor = editor();
|
||||
|
||||
+8
-3
@@ -1279,8 +1279,13 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn handle_diff(&mut self, input: Input) {
|
||||
// Escape and Tab always retain their modal close/back/focus semantics.
|
||||
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
|
||||
// Modal controls take precedence over configured browsing bindings.
|
||||
let toggle_files = input == Input::Ctrl('e')
|
||||
&& self
|
||||
.diff
|
||||
.as_ref()
|
||||
.is_some_and(|diff| diff.phase == Phase::View);
|
||||
if !toggle_files && !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
|
||||
if self.handle_tree_binding(&input) {
|
||||
return;
|
||||
}
|
||||
@@ -2620,7 +2625,7 @@ impl Editor {
|
||||
|
||||
fn handle_tree_binding(&mut self, input: &Input) -> bool {
|
||||
let picker = if let Some(diff) = &mut self.diff {
|
||||
diff.files.as_mut()
|
||||
diff.files.as_mut().filter(|_| diff.files_visible)
|
||||
} else {
|
||||
self.file_picker.as_mut()
|
||||
};
|
||||
|
||||
+47
-5
@@ -38,6 +38,8 @@ pub struct FileRow<'a> {
|
||||
/// The actual project-relative OS path, never reconstructed from the label.
|
||||
pub path: &'a Path,
|
||||
pub label: &'a str,
|
||||
/// Original virtual-catalog input index; absent for scanned entries and directories.
|
||||
pub catalog_index: Option<usize>,
|
||||
pub is_dir: bool,
|
||||
pub depth: usize,
|
||||
pub expanded: bool,
|
||||
@@ -48,6 +50,7 @@ struct Node {
|
||||
path: PathBuf,
|
||||
label: String,
|
||||
search_label: String,
|
||||
catalog_index: Option<usize>,
|
||||
is_dir: bool,
|
||||
parent: Option<usize>,
|
||||
children: Vec<usize>,
|
||||
@@ -153,6 +156,7 @@ impl FilePicker {
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index: None,
|
||||
is_dir: entry.file_type().unwrap().is_dir(),
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
@@ -163,25 +167,26 @@ impl FilePicker {
|
||||
|
||||
/// Builds the changed-path catalog without consulting the working tree.
|
||||
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
let mut catalog = HashSet::new();
|
||||
for path in paths {
|
||||
let mut catalog = HashMap::new();
|
||||
for (index, path) in paths.into_iter().enumerate() {
|
||||
for parent in path
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
{
|
||||
catalog.insert((parent.to_owned(), true));
|
||||
catalog.insert((parent.to_owned(), true), None);
|
||||
}
|
||||
catalog.insert((path, false));
|
||||
catalog.entry((path, false)).or_insert(Some(index));
|
||||
}
|
||||
let nodes = catalog
|
||||
.into_iter()
|
||||
.map(|(path, is_dir)| {
|
||||
.map(|((path, is_dir), catalog_index)| {
|
||||
let label = label(&path);
|
||||
Node {
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index,
|
||||
is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
@@ -310,6 +315,7 @@ impl FilePicker {
|
||||
.tree
|
||||
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
|
||||
is_dir: node.is_dir,
|
||||
catalog_index: node.catalog_index,
|
||||
depth: row.depth,
|
||||
expanded: row.expanded,
|
||||
}
|
||||
@@ -797,6 +803,30 @@ mod tests {
|
||||
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
||||
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
||||
let expected_index = |row: &FileRow<'_>| {
|
||||
if row.is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(match row.path.to_str().unwrap() {
|
||||
"x" => 0,
|
||||
"x/y" => 1,
|
||||
"src/deep/deleted.rs" => 2,
|
||||
path => panic!("unexpected path {path}"),
|
||||
})
|
||||
}
|
||||
};
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "x", "deleted"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert_eq!(row.catalog_index, expected_index(&row));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -831,6 +861,18 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||
let directory = Directory::new();
|
||||
directory.put("nested/file.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
picker.reveal_path(Path::new("nested/file.txt"));
|
||||
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
|
||||
@@ -43,6 +43,7 @@ Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers
|
||||
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed-file tree: Ctrl+e hides/shows; A added, M modified, D removed, R renamed (old path shown)
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
+227
-26
@@ -861,9 +861,23 @@ fn draw(
|
||||
if picker.presentation == FilePresentation::Floating {
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
panels,
|
||||
None,
|
||||
);
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
panels,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
@@ -1140,7 +1154,7 @@ fn draw_diff(
|
||||
notice_area,
|
||||
);
|
||||
}
|
||||
let (panes, navigator) = if diff.files.is_some() {
|
||||
let (panes, navigator) = if diff.files_visible && diff.files.is_some() {
|
||||
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
|
||||
} else {
|
||||
(workspace, None)
|
||||
@@ -1150,7 +1164,14 @@ fn draw_diff(
|
||||
if let Some(picker) = &diff.files
|
||||
&& let Some(navigator) = navigator
|
||||
{
|
||||
draw_file_list(frame, picker, navigator, file_navigation_hint, panels);
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
navigator,
|
||||
file_navigation_hint,
|
||||
panels,
|
||||
comparison.map(|comparison| comparison.files.as_slice()),
|
||||
);
|
||||
}
|
||||
if let Some(file) = selected {
|
||||
draw_diff_pane(frame, diff, file, before_area, true);
|
||||
@@ -1167,7 +1188,7 @@ fn draw_diff(
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
|
||||
"^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
|
||||
)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help,
|
||||
@@ -1740,6 +1761,7 @@ fn draw_file_list(
|
||||
panel: Rect,
|
||||
navigation_hint: &str,
|
||||
panels: &mut WheelPanels,
|
||||
changed_files: Option<&[ChangedFile]>,
|
||||
) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
@@ -1862,20 +1884,46 @@ fn draw_file_list(
|
||||
})
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let label = match picker.view {
|
||||
FileView::List => {
|
||||
format!("{} {}", if selected { ">" } else { " " }, entry.label)
|
||||
}
|
||||
FileView::Tree => format!(
|
||||
"{} {:indent$}{}{}",
|
||||
if selected { ">" } else { " " },
|
||||
"",
|
||||
entry.label,
|
||||
if entry.is_dir { "/" } else { "" },
|
||||
indent = entry.depth * 2,
|
||||
),
|
||||
};
|
||||
Line::styled(label, style)
|
||||
let changed = entry
|
||||
.catalog_index
|
||||
.and_then(|index| changed_files.and_then(|files| files.get(index)));
|
||||
let prefix = format!(
|
||||
"{} {:indent$}",
|
||||
if selected { ">" } else { " " },
|
||||
"",
|
||||
indent = if picker.view == FileView::Tree {
|
||||
entry.depth * 2
|
||||
} else {
|
||||
0
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 });
|
||||
spans.push(Span::raw(prefix));
|
||||
if let Some(file) = changed {
|
||||
let color = match file.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),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::raw(entry.label));
|
||||
if entry.is_dir && picker.view == FileView::Tree {
|
||||
spans.push(Span::raw("/"));
|
||||
}
|
||||
if let Some(file) = changed
|
||||
&& file.status == FileStatus::Renamed
|
||||
&& let Some(old_path) = &file.old_path
|
||||
{
|
||||
spans.push(Span::raw(" ← "));
|
||||
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
|
||||
}
|
||||
Line::from(spans).style(style)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
@@ -1891,7 +1939,9 @@ fn draw_file_list(
|
||||
));
|
||||
}
|
||||
}
|
||||
let help = if picker.focused {
|
||||
let help = if changed_files.is_some() {
|
||||
"A added · M modified · D deleted · R renamed · ^E hide".into()
|
||||
} else if picker.focused {
|
||||
let controls = if picker.root.as_os_str().is_empty() {
|
||||
if browsing {
|
||||
"Enter · i filter · Esc close · Tab panes · ^T view"
|
||||
@@ -2299,7 +2349,6 @@ mod tests {
|
||||
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
||||
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
|
||||
}
|
||||
assert!(screen_text(&terminal).contains("> added.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2328,7 +2377,6 @@ mod tests {
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> new-name.txt"));
|
||||
assert!(text.contains("Before · R · old-name.txt"));
|
||||
assert!(text.contains("After · R · new-name.txt"));
|
||||
let geometry = panels.diff.as_ref().unwrap();
|
||||
@@ -2417,6 +2465,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() {
|
||||
let files: Vec<_> = [
|
||||
("z.txt", FileStatus::Added),
|
||||
("chain/a/m.txt", FileStatus::Modified),
|
||||
("d.txt", FileStatus::Removed),
|
||||
("r.txt", FileStatus::Renamed),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, status)| {
|
||||
let mut file = deletion_file();
|
||||
file.path = path.into();
|
||||
file.status = status;
|
||||
file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into());
|
||||
file
|
||||
})
|
||||
.collect();
|
||||
let mut picker =
|
||||
FilePicker::from_paths(PathBuf::new(), files.iter().map(|file| file.path.clone()));
|
||||
picker.reveal_path(std::path::Path::new("chain/a/m.txt"));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||
for view in [FileView::Tree, FileView::List] {
|
||||
picker.set_view(view);
|
||||
for focused in [true, false] {
|
||||
picker.focused = focused;
|
||||
for index in 0..files.len() {
|
||||
picker.reveal_path(&files[index].path);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
Some(&files),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (file, color) in
|
||||
files
|
||||
.iter()
|
||||
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
|
||||
{
|
||||
let path = if view == FileView::Tree {
|
||||
std::path::Path::new(file.path.file_name().unwrap())
|
||||
} else {
|
||||
file.path.as_path()
|
||||
};
|
||||
let expected =
|
||||
format!("{} {}", diff_status(file.status), path.to_string_lossy());
|
||||
let (x, y) = (0..16)
|
||||
.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!(screen_text(&terminal).contains("r.txt ← old�name.txt"));
|
||||
assert_eq!(picker.accept(), Some(files[index].path.clone()));
|
||||
assert!(picker.query.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
picker.query = "m.txt".into();
|
||||
picker.refilter();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
Some(&files),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("M chain/a/m.txt"));
|
||||
assert!(!screen_text(&terminal).contains("R r.txt"));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("M chain/a/m.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
|
||||
let mut diff = diff_comparison(vec![deletion_file()]);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let original = panels.diff.as_ref().unwrap().regions;
|
||||
diff.handle(Input::Ctrl('e'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let hidden = panels.diff.as_ref().unwrap().regions;
|
||||
assert_eq!(hidden[0].0, Rect::default());
|
||||
assert!(panels.files.is_none());
|
||||
assert_eq!(hidden[1].0.x, 0);
|
||||
assert_eq!(hidden[1].0.right(), hidden[2].0.x);
|
||||
assert_eq!(hidden[2].0.right(), 120);
|
||||
assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width);
|
||||
assert!(!screen_text(&terminal).contains("Explorer"));
|
||||
diff.handle(Input::Ctrl('e'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert_eq!(panels.diff.as_ref().unwrap().regions, original);
|
||||
assert_eq!(panels.files.unwrap().0, original[0].0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
@@ -2443,7 +2615,6 @@ mod tests {
|
||||
}]);
|
||||
render_diff(&mut terminal, &binary, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Binary file"));
|
||||
assert!(screen_text(&terminal).contains("> image.bin"));
|
||||
let blank = diff_comparison(vec![ChangedFile {
|
||||
path: "empty.txt".into(),
|
||||
old_path: None,
|
||||
@@ -2613,6 +2784,20 @@ mod tests {
|
||||
assert_eq!(diff.focus, Focus::After);
|
||||
assert_eq!(diff.top, 0);
|
||||
assert_eq!(diff.file, 3);
|
||||
let selected = diff.files.as_ref().unwrap().selected;
|
||||
session.handle(Input::Ctrl('e'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
assert!(viewports.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
|
||||
));
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(diff.focus, Focus::After);
|
||||
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
|
||||
assert!(!diff.files.as_ref().unwrap().focused);
|
||||
assert!(viewports.panels.files.is_none());
|
||||
assert_eq!(session.editor().lines(), original);
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
assert!(viewports.document.geometry.is_none());
|
||||
@@ -3892,7 +4077,7 @@ end
|
||||
let area = Rect::new(3, 2, 40, 6);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, picker, area, "", &mut panels))
|
||||
.draw(|frame| draw_file_list(frame, picker, area, "", &mut panels, None))
|
||||
.unwrap();
|
||||
let mouse = MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
@@ -3919,6 +4104,7 @@ end
|
||||
area,
|
||||
"",
|
||||
&mut panels,
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
@@ -3966,6 +4152,7 @@ end
|
||||
area,
|
||||
"",
|
||||
&mut panels,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4010,7 +4197,7 @@ end
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.set_cursor_position((59, 11));
|
||||
draw_file_list(frame, &picker, area, "", &mut panels);
|
||||
draw_file_list(frame, &picker, area, "", &mut panels, None);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
@@ -4027,7 +4214,7 @@ end
|
||||
}
|
||||
picker.navigate(false);
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels))
|
||||
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None))
|
||||
.unwrap();
|
||||
assert!(panels.manual_files.is_none());
|
||||
assert!(screen_text(&terminal).contains("> file-10"));
|
||||
@@ -4060,6 +4247,7 @@ end
|
||||
frame.area(),
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4167,6 +4355,7 @@ end
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4212,6 +4401,7 @@ end
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4235,6 +4425,7 @@ end
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4258,6 +4449,7 @@ end
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4274,6 +4466,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4294,6 +4487,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4331,6 +4525,7 @@ end
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4388,6 +4583,7 @@ end
|
||||
frame.area(),
|
||||
editor.selection_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4438,6 +4634,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4489,6 +4686,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4536,6 +4734,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4567,6 +4766,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4584,6 +4784,7 @@ end
|
||||
area,
|
||||
editor.file_navigation_hint(),
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user