feat: reuse the docked file tree for diff navigation
This commit is contained in:
@@ -10,7 +10,7 @@ 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.
|
- `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.
|
- `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. It has no subprocess or terminal dependencies.
|
- `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.
|
||||||
- `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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree
|
|||||||
config.bind "ctrl+h", nil, mode: config.modes.tree
|
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.
|
||||||
|
|
||||||
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+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.
|
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.
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ 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.
|
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.
|
||||||
|
|
||||||
## Performance and process limits
|
## Performance and process limits
|
||||||
|
|
||||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||||
|
|||||||
+3
-1
@@ -209,7 +209,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
|||||||
|
|
||||||
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||||
|
|
||||||
The read-only comparison has a changed-file navigator, Before on the left, and After on the right. Added/deleted rows are aligned with blank counterparts. Tab/Shift+Tab cycles focus between Files, Before, and After. Up/Down or Ctrl+j/k selects files or scrolls both text panes together; Left/Right or Ctrl+h/l scrolls the focused text pane horizontally. Home/End goes to the first/last file or aligned row. Wheel input over a text pane scrolls both panes; over Files it navigates changed files. Esc closes and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
|
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.
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+191
-35
@@ -1,4 +1,5 @@
|
|||||||
use crate::editor::Input;
|
use crate::editor::Input;
|
||||||
|
use crate::files::{FilePicker, FileView};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
@@ -146,6 +147,7 @@ pub struct DiffState {
|
|||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub comparison: Option<Comparison>,
|
pub comparison: Option<Comparison>,
|
||||||
|
pub files: Option<FilePicker>,
|
||||||
pub file: usize,
|
pub file: usize,
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub top: usize,
|
pub top: usize,
|
||||||
@@ -174,6 +176,7 @@ impl DiffState {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
comparison: None,
|
comparison: None,
|
||||||
|
files: None,
|
||||||
file: 0,
|
file: 0,
|
||||||
focus: Focus::Files,
|
focus: Focus::Files,
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -240,6 +243,14 @@ impl DiffState {
|
|||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||||
|
let mut files = FilePicker::from_paths(
|
||||||
|
PathBuf::new(),
|
||||||
|
comparison.files.iter().map(|file| file.path.clone()),
|
||||||
|
);
|
||||||
|
if let Some(file) = comparison.files.first() {
|
||||||
|
files.reveal_path(&file.path);
|
||||||
|
}
|
||||||
|
self.files = Some(files);
|
||||||
self.comparison = Some(comparison);
|
self.comparison = Some(comparison);
|
||||||
self.phase = Phase::View;
|
self.phase = Phase::View;
|
||||||
self.focus = Focus::Files;
|
self.focus = Focus::Files;
|
||||||
@@ -295,18 +306,8 @@ impl DiffState {
|
|||||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||||
if self.phase == Phase::View {
|
if self.phase == Phase::View {
|
||||||
if self.focus == Focus::Files {
|
if self.focus == Focus::Files {
|
||||||
let count = self
|
if let Some(files) = &mut self.files {
|
||||||
.comparison
|
files.navigate(forward);
|
||||||
.as_ref()
|
|
||||||
.map_or(0, |comparison| comparison.files.len());
|
|
||||||
let file = if forward {
|
|
||||||
self.file.saturating_add(1).min(count.saturating_sub(1))
|
|
||||||
} else {
|
|
||||||
self.file.saturating_sub(1)
|
|
||||||
};
|
|
||||||
if file != self.file {
|
|
||||||
self.file = file;
|
|
||||||
self.align_file();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.top = if forward {
|
self.top = if forward {
|
||||||
@@ -334,6 +335,15 @@ impl DiffState {
|
|||||||
/// Returns true only when the modal should close. All other inputs are consumed.
|
/// Returns true only when the modal should close. All other inputs are consumed.
|
||||||
pub(crate) fn handle(&mut self, input: Input) -> bool {
|
pub(crate) fn handle(&mut self, input: Input) -> bool {
|
||||||
if input == Input::Escape {
|
if input == Input::Escape {
|
||||||
|
if self.phase == Phase::View
|
||||||
|
&& self.focus == Focus::Files
|
||||||
|
&& let Some(files) = &mut self.files
|
||||||
|
&& files.searching
|
||||||
|
&& files.view == FileView::Tree
|
||||||
|
{
|
||||||
|
files.finish_search();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if self.phase != Phase::After {
|
if self.phase != Phase::After {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -424,6 +434,58 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_view(&mut self, input: Input) {
|
fn handle_view(&mut self, input: Input) {
|
||||||
|
if self.focus == Focus::Files {
|
||||||
|
let files = self.files.as_mut().expect("comparison file picker");
|
||||||
|
match input {
|
||||||
|
Input::Char('\t') | Input::BackTab => {}
|
||||||
|
Input::Ctrl('t') => {
|
||||||
|
files.toggle_view();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Home => {
|
||||||
|
files.selected = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::End => {
|
||||||
|
let last = files.entries().len().saturating_sub(1);
|
||||||
|
files.selected = last;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Up | Input::Ctrl('k') => {
|
||||||
|
files.navigate(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Down | Input::Ctrl('j') => {
|
||||||
|
files.navigate(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Left | Input::Ctrl('h') => {
|
||||||
|
files.left();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Right | Input::Ctrl('l') => {
|
||||||
|
files.right();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Input::Enter => {
|
||||||
|
if let Some(path) = files.accept()
|
||||||
|
&& let Some(index) = self.comparison.as_ref().and_then(|comparison| {
|
||||||
|
comparison.files.iter().position(|file| file.path == path)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
self.file = index;
|
||||||
|
self.focus = Focus::Before;
|
||||||
|
self.files.as_mut().expect("comparison picker").focused = false;
|
||||||
|
self.align_file();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
files.handle_filter_input(&input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
match input {
|
match input {
|
||||||
Input::Char('\t') => {
|
Input::Char('\t') => {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
@@ -441,12 +503,6 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
Input::Up | Input::Ctrl('k') => self.navigate(false),
|
Input::Up | Input::Ctrl('k') => self.navigate(false),
|
||||||
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
||||||
Input::Right | Input::Ctrl('l') if self.focus == Focus::Files => {
|
|
||||||
self.focus = Focus::Before
|
|
||||||
}
|
|
||||||
Input::Left | Input::Ctrl('h') if self.focus == Focus::Files => {
|
|
||||||
self.focus = Focus::After
|
|
||||||
}
|
|
||||||
Input::Left | Input::Ctrl('h') => match self.focus {
|
Input::Left | Input::Ctrl('h') => match self.focus {
|
||||||
Focus::Before => self.left_before = self.left_before.saturating_sub(1),
|
Focus::Before => self.left_before = self.left_before.saturating_sub(1),
|
||||||
Focus::After => self.left_after = self.left_after.saturating_sub(1),
|
Focus::After => self.left_after = self.left_after.saturating_sub(1),
|
||||||
@@ -462,25 +518,17 @@ impl DiffState {
|
|||||||
Focus::Files => {}
|
Focus::Files => {}
|
||||||
},
|
},
|
||||||
Input::Home | Input::End => {
|
Input::Home | Input::End => {
|
||||||
if self.focus == Focus::Files {
|
self.top = if input == Input::Home {
|
||||||
self.file = if input == Input::Home {
|
0
|
||||||
0
|
|
||||||
} else {
|
|
||||||
self.comparison
|
|
||||||
.as_ref()
|
|
||||||
.map_or(0, |comparison| comparison.files.len().saturating_sub(1))
|
|
||||||
};
|
|
||||||
self.align_file();
|
|
||||||
} else {
|
} else {
|
||||||
self.top = if input == Input::Home {
|
self.rows.len().saturating_sub(1)
|
||||||
0
|
};
|
||||||
} else {
|
|
||||||
self.rows.len().saturating_sub(1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
if let Some(files) = &mut self.files {
|
||||||
|
files.focused = self.focus == Focus::Files;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn align_file(&mut self) {
|
fn align_file(&mut self) {
|
||||||
@@ -859,6 +907,107 @@ mod tests {
|
|||||||
assert!(editor.diff().is_none());
|
assert!(editor.diff().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_to_directory_change_opens_both_historical_paths() {
|
||||||
|
let mut editor = editor();
|
||||||
|
open(&mut editor);
|
||||||
|
let id = choose_pair(&mut editor);
|
||||||
|
let mut changes = comparison();
|
||||||
|
changes.files[0].path = "x".into();
|
||||||
|
changes.files[1].path = "x/y".into();
|
||||||
|
changes.files.truncate(2);
|
||||||
|
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||||
|
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
assert_eq!(editor.diff().unwrap().file, 0);
|
||||||
|
editor.handle(Input::BackTab);
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
text(&mut editor, "x/y");
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
assert_eq!(editor.diff().unwrap().file, 1);
|
||||||
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_tree_filters_navigates_and_opens_without_touching_buffers() {
|
||||||
|
let mut editor = editor();
|
||||||
|
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();
|
||||||
|
changes.files[2].path = "archive/gone/removed.bin".into();
|
||||||
|
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||||
|
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||||
|
assert!(picker.focused && !picker.searching);
|
||||||
|
assert_eq!(
|
||||||
|
picker.entries().nth(picker.selected).unwrap().path,
|
||||||
|
std::path::Path::new("src/deep/renamed.txt")
|
||||||
|
);
|
||||||
|
assert!(picker.entries().any(|row| row.label == "src/deep"));
|
||||||
|
editor.bind("tree", "z", Some("core.left".into())).unwrap();
|
||||||
|
editor.handle(Input::Char('z'));
|
||||||
|
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||||
|
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||||
|
assert_eq!(editor.diff().unwrap().file, 0);
|
||||||
|
editor.handle(Input::Char('l'));
|
||||||
|
editor.handle(Input::Char('l'));
|
||||||
|
editor
|
||||||
|
.bind("selection", "ctrl+n", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Ctrl('n'));
|
||||||
|
assert_eq!(editor.diff().unwrap().file, 0);
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
text(&mut editor, "removed.bin");
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
let state = editor.diff().unwrap();
|
||||||
|
assert_eq!(state.file, 2);
|
||||||
|
assert_eq!(state.focus, Focus::Before);
|
||||||
|
assert!(!state.files.as_ref().unwrap().focused);
|
||||||
|
assert!(state.rows.is_empty());
|
||||||
|
editor.handle(Input::BackTab);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.files
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.query
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
editor.handle(Input::Ctrl('t'));
|
||||||
|
assert_eq!(
|
||||||
|
editor.diff().unwrap().files.as_ref().unwrap().view,
|
||||||
|
FileView::List
|
||||||
|
);
|
||||||
|
editor.handle(Input::Ctrl('t'));
|
||||||
|
assert_eq!(
|
||||||
|
editor.diff().unwrap().files.as_ref().unwrap().view,
|
||||||
|
FileView::Tree
|
||||||
|
);
|
||||||
|
editor.handle(Input::Ctrl('r'));
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
text(&mut editor, "removed.bin");
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
|
assert_eq!(editor.context().text, original);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert!(editor.diff().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
|
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
@@ -901,6 +1050,8 @@ mod tests {
|
|||||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||||
editor.handle(Input::BackTab);
|
editor.handle(Input::BackTab);
|
||||||
editor.handle(Input::Down);
|
editor.handle(Input::Down);
|
||||||
|
assert_eq!(editor.diff().unwrap().file, 0);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
let state = editor.diff().unwrap();
|
let state = editor.diff().unwrap();
|
||||||
assert_eq!(state.file, 1);
|
assert_eq!(state.file, 1);
|
||||||
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
|
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
|
||||||
@@ -910,7 +1061,9 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
|
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
|
||||||
);
|
);
|
||||||
editor.handle(Input::End);
|
editor.handle(Input::BackTab);
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
assert_eq!(editor.diff().unwrap().file, 2);
|
assert_eq!(editor.diff().unwrap().file, 2);
|
||||||
assert!(editor.diff().unwrap().rows.is_empty());
|
assert!(editor.diff().unwrap().rows.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -918,7 +1071,9 @@ mod tests {
|
|||||||
.notice
|
.notice
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
editor.handle(Input::Home);
|
editor.handle(Input::BackTab);
|
||||||
|
editor.handle(Input::Down);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
assert_eq!(editor.diff().unwrap().file, 0);
|
assert_eq!(editor.diff().unwrap().file, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1171,7 @@ mod tests {
|
|||||||
assert_eq!(editor.context().text, original);
|
assert_eq!(editor.context().text, original);
|
||||||
assert_eq!(editor.carets(), carets);
|
assert_eq!(editor.carets(), carets);
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
editor.handle(Input::Char('u'));
|
editor.handle(Input::Char('u'));
|
||||||
assert_eq!(editor.context().text, "original\\nsecond");
|
assert_eq!(editor.context().text, "original\\nsecond");
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-18
@@ -1281,6 +1281,9 @@ impl Editor {
|
|||||||
fn handle_diff(&mut self, input: Input) {
|
fn handle_diff(&mut self, input: Input) {
|
||||||
// Escape and Tab always retain their modal close/back/focus semantics.
|
// Escape and Tab always retain their modal close/back/focus semantics.
|
||||||
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
|
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
|
||||||
|
if self.handle_tree_binding(&input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match self.selection_bindings.get(&input) {
|
match self.selection_bindings.get(&input) {
|
||||||
Some(Binding::Action(Action::Down)) => {
|
Some(Binding::Action(Action::Down)) => {
|
||||||
self.diff.as_mut().expect("active diff").navigate(true);
|
self.diff.as_mut().expect("active diff").navigate(true);
|
||||||
@@ -2544,7 +2547,11 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn file_navigation_hint(&self) -> &str {
|
pub fn file_navigation_hint(&self) -> &str {
|
||||||
if let Some(picker) = &self.file_picker
|
if let Some(picker) = self
|
||||||
|
.diff
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|diff| diff.files.as_ref())
|
||||||
|
.or(self.file_picker.as_ref())
|
||||||
&& picker.view == FileView::Tree
|
&& picker.view == FileView::Tree
|
||||||
{
|
{
|
||||||
if picker.searching {
|
if picker.searching {
|
||||||
@@ -2612,10 +2619,12 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_tree_binding(&mut self, input: &Input) -> bool {
|
fn handle_tree_binding(&mut self, input: &Input) -> bool {
|
||||||
let Some(picker) = self
|
let picker = if let Some(diff) = &mut self.diff {
|
||||||
.file_picker
|
diff.files.as_mut()
|
||||||
.as_mut()
|
} else {
|
||||||
.filter(|picker| picker.focused && picker.view == FileView::Tree)
|
self.file_picker.as_mut()
|
||||||
|
};
|
||||||
|
let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree)
|
||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -2897,19 +2906,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
Input::Ctrl('r') => picker.refresh(),
|
Input::Ctrl('r') => picker.refresh(),
|
||||||
Input::Ctrl('t') => picker.toggle_view(),
|
Input::Ctrl('t') => picker.toggle_view(),
|
||||||
Input::Ctrl('u') => picker.clear_filter(),
|
_ if picker.handle_filter_input(&input) => {}
|
||||||
Input::Backspace if picker.view == FileView::List || picker.searching => {
|
|
||||||
picker.backspace()
|
|
||||||
}
|
|
||||||
Input::Char('i') if picker.view == FileView::Tree && !picker.searching => {
|
|
||||||
picker.start_search()
|
|
||||||
}
|
|
||||||
Input::Char(ch)
|
|
||||||
if !ch.is_control() && (picker.view == FileView::List || picker.searching) =>
|
|
||||||
{
|
|
||||||
picker.query.push(ch);
|
|
||||||
picker.refilter();
|
|
||||||
}
|
|
||||||
Input::Enter => {
|
Input::Enter => {
|
||||||
let Some(path) = picker.accept() else { return };
|
let Some(path) = picker.accept() else { return };
|
||||||
let source = self.jump_location();
|
let source = self.jump_location();
|
||||||
|
|||||||
+100
-1
@@ -43,6 +43,7 @@ pub struct FileRow<'a> {
|
|||||||
pub expanded: bool,
|
pub expanded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct Node {
|
struct Node {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
label: String,
|
label: String,
|
||||||
@@ -52,6 +53,7 @@ struct Node {
|
|||||||
children: Vec<usize>,
|
children: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct TreeNode {
|
struct TreeNode {
|
||||||
node: usize,
|
node: usize,
|
||||||
label: String,
|
label: String,
|
||||||
@@ -59,6 +61,7 @@ struct TreeNode {
|
|||||||
children: Vec<usize>,
|
children: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct VisibleRow {
|
struct VisibleRow {
|
||||||
node: usize,
|
node: usize,
|
||||||
tree: Option<usize>,
|
tree: Option<usize>,
|
||||||
@@ -66,6 +69,7 @@ struct VisibleRow {
|
|||||||
expanded: bool,
|
expanded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct FilePicker {
|
pub struct FilePicker {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -154,10 +158,51 @@ impl FilePicker {
|
|||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
for parent in path
|
||||||
|
.ancestors()
|
||||||
|
.skip(1)
|
||||||
|
.filter(|path| !path.as_os_str().is_empty())
|
||||||
|
{
|
||||||
|
catalog.insert((parent.to_owned(), true));
|
||||||
|
}
|
||||||
|
catalog.insert((path, false));
|
||||||
|
}
|
||||||
|
let nodes = catalog
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, is_dir)| {
|
||||||
|
let label = label(&path);
|
||||||
|
Node {
|
||||||
|
path,
|
||||||
|
search_label: label.to_lowercase(),
|
||||||
|
label,
|
||||||
|
is_dir,
|
||||||
|
parent: None,
|
||||||
|
children: Vec::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_nodes(
|
||||||
|
root: PathBuf,
|
||||||
|
mut nodes: Vec<Node>,
|
||||||
|
presentation: FilePresentation,
|
||||||
|
view: FileView,
|
||||||
|
warning: Option<String>,
|
||||||
|
) -> Self {
|
||||||
nodes.sort_by(|left, right| left.path.cmp(&right.path));
|
nodes.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
let by_path: HashMap<_, _> = nodes
|
let by_path: HashMap<_, _> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
.filter(|(_, node)| node.is_dir)
|
||||||
.map(|(index, node)| (node.path.clone(), index))
|
.map(|(index, node)| (node.path.clone(), index))
|
||||||
.collect();
|
.collect();
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
@@ -223,7 +268,7 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
picker.project(None, false);
|
picker.project(None, false);
|
||||||
picker.remember_selection(false);
|
picker.remember_selection(false);
|
||||||
Ok(picker)
|
picker
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
|
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
|
||||||
@@ -321,6 +366,37 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reveal_path(&mut self, path: &Path) {
|
||||||
|
if let Some(node) = self
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.position(|node| node.path == path && !node.is_dir)
|
||||||
|
{
|
||||||
|
self.reveal(node);
|
||||||
|
self.project(Some(node), false);
|
||||||
|
self.remember_selection(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
|
||||||
|
use crate::editor::Input;
|
||||||
|
match input {
|
||||||
|
Input::Ctrl('u') => self.clear_filter(),
|
||||||
|
Input::Backspace if self.view == FileView::List || self.searching => self.backspace(),
|
||||||
|
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
|
||||||
|
self.start_search()
|
||||||
|
}
|
||||||
|
Input::Char(ch)
|
||||||
|
if !ch.is_control() && (self.view == FileView::List || self.searching) =>
|
||||||
|
{
|
||||||
|
self.query.push(*ch);
|
||||||
|
self.refilter();
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_view(&mut self, view: FileView) {
|
pub fn set_view(&mut self, view: FileView) {
|
||||||
if self.view == view {
|
if self.view == view {
|
||||||
return;
|
return;
|
||||||
@@ -699,6 +775,29 @@ impl FilePicker {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||||
|
let mut picker = FilePicker::from_paths(
|
||||||
|
PathBuf::new(),
|
||||||
|
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.entries()
|
||||||
|
.map(|row| row.label.to_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["src/deep", "x", "x"]
|
||||||
|
);
|
||||||
|
picker.reveal_path(Path::new("x"));
|
||||||
|
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||||
|
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
||||||
|
picker.reveal_path(Path::new("x/y"));
|
||||||
|
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
||||||
|
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);
|
||||||
|
}
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|||||||
+206
-61
@@ -422,6 +422,7 @@ fn draw(
|
|||||||
diff,
|
diff,
|
||||||
area,
|
area,
|
||||||
editor.selection_navigation_hint(),
|
editor.selection_navigation_hint(),
|
||||||
|
editor.file_navigation_hint(),
|
||||||
panels,
|
panels,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -907,6 +908,7 @@ fn draw_diff(
|
|||||||
diff: &DiffState,
|
diff: &DiffState,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
navigation_hint: &str,
|
navigation_hint: &str,
|
||||||
|
file_navigation_hint: &str,
|
||||||
panels: &mut WheelPanels,
|
panels: &mut WheelPanels,
|
||||||
) {
|
) {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
@@ -1138,59 +1140,17 @@ fn draw_diff(
|
|||||||
notice_area,
|
notice_area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let navigator_width = if workspace.width >= 40 {
|
let (panes, navigator) = if diff.files.is_some() {
|
||||||
(workspace.width / 4).min(32)
|
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
|
||||||
} else {
|
} else {
|
||||||
0
|
(workspace, None)
|
||||||
};
|
};
|
||||||
let [navigator, panes] =
|
|
||||||
Layout::horizontal([Constraint::Length(navigator_width), Constraint::Fill(1)])
|
|
||||||
.areas(workspace);
|
|
||||||
let [before_area, after_area] =
|
let [before_area, after_area] =
|
||||||
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
|
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
|
||||||
let navigator_inner = diff_panel(
|
if let Some(picker) = &diff.files
|
||||||
frame,
|
&& let Some(navigator) = navigator
|
||||||
navigator,
|
|
||||||
" Changed files ",
|
|
||||||
diff.focus == Focus::Files,
|
|
||||||
);
|
|
||||||
if let Some(comparison) = comparison
|
|
||||||
&& navigator_inner.width > 0
|
|
||||||
&& navigator_inner.height > 0
|
|
||||||
{
|
{
|
||||||
let height = usize::from(navigator_inner.height);
|
draw_file_list(frame, picker, navigator, file_navigation_hint, panels);
|
||||||
let first = diff.file.saturating_sub(height.saturating_sub(1));
|
|
||||||
let lines: Vec<Line> = comparison
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.skip(first)
|
|
||||||
.take(height)
|
|
||||||
.map(|(index, file)| {
|
|
||||||
let label = format!(
|
|
||||||
"{} {} {}",
|
|
||||||
if index == diff.file { ">" } else { " " },
|
|
||||||
diff_status(file.status),
|
|
||||||
display_text(&file.path.to_string_lossy())
|
|
||||||
);
|
|
||||||
let style = if index == diff.file {
|
|
||||||
Style::default()
|
|
||||||
.bg(if diff.focus == Focus::Files {
|
|
||||||
Color::Blue
|
|
||||||
} else {
|
|
||||||
Color::DarkGray
|
|
||||||
})
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
Line::styled(
|
|
||||||
visible_line(&label, 0, usize::from(navigator_inner.width)),
|
|
||||||
style,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
frame.render_widget(Paragraph::new(lines), navigator_inner);
|
|
||||||
}
|
}
|
||||||
if let Some(file) = selected {
|
if let Some(file) = selected {
|
||||||
draw_diff_pane(frame, diff, file, before_area, true);
|
draw_diff_pane(frame, diff, file, before_area, true);
|
||||||
@@ -1207,14 +1167,14 @@ fn draw_diff(
|
|||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(
|
Paragraph::new(
|
||||||
"Tab focus · ↑/↓ scroll/files · ←/→ horizontal · Home/End · Esc close · read-only",
|
"Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · ←/→ horizontal · Esc close",
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::DarkGray)),
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
help,
|
help,
|
||||||
);
|
);
|
||||||
panels.diff = Some(DiffGeometry {
|
panels.diff = Some(DiffGeometry {
|
||||||
regions: [
|
regions: [
|
||||||
(navigator, Focus::Files),
|
(navigator.unwrap_or_default(), Focus::Files),
|
||||||
(before_area, Focus::Before),
|
(before_area, Focus::Before),
|
||||||
(after_area, Focus::After),
|
(after_area, Focus::After),
|
||||||
],
|
],
|
||||||
@@ -1811,7 +1771,7 @@ fn draw_file_list(
|
|||||||
if bordered {
|
if bordered {
|
||||||
frame.render_widget(block, panel);
|
frame.render_widget(block, panel);
|
||||||
}
|
}
|
||||||
let show_root = content.height >= 4;
|
let show_root = content.height >= 4 && !picker.root.as_os_str().is_empty();
|
||||||
let show_error = picker.error.is_some() && content.height >= 2;
|
let show_error = picker.error.is_some() && content.height >= 2;
|
||||||
let show_help = content.height >= 5 + u16::from(show_error);
|
let show_help = content.height >= 5 + u16::from(show_error);
|
||||||
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
||||||
@@ -1932,14 +1892,26 @@ fn draw_file_list(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let help = if picker.focused {
|
let help = if picker.focused {
|
||||||
let controls = match (picker.presentation, browsing) {
|
let controls = if picker.root.as_os_str().is_empty() {
|
||||||
(FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T",
|
if browsing {
|
||||||
(FilePresentation::Docked, true) => "Enter · i filter · Esc/Tab edit · ^W · ^R · ^T",
|
"Enter · i filter · Esc close · Tab panes · ^T view"
|
||||||
(_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T",
|
} else {
|
||||||
(FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T",
|
"Enter · Esc · Tab panes · ^T view"
|
||||||
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
|
}
|
||||||
|
} else {
|
||||||
|
match (picker.presentation, browsing) {
|
||||||
|
(FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T",
|
||||||
|
(FilePresentation::Docked, true) => {
|
||||||
|
"Enter · i filter · Esc/Tab edit · ^W · ^R · ^T"
|
||||||
|
}
|
||||||
|
(_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T",
|
||||||
|
(FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T",
|
||||||
|
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
format!("^U clear · {navigation_hint} · {controls}")
|
format!("^U clear · {navigation_hint} · {controls}")
|
||||||
|
} else if picker.root.as_os_str().is_empty() {
|
||||||
|
"Editor focus · Tab to browse changed files".into()
|
||||||
} else {
|
} else {
|
||||||
"Editor focus · invoke explorer to browse".into()
|
"Editor focus · invoke explorer to browse".into()
|
||||||
};
|
};
|
||||||
@@ -2212,7 +2184,7 @@ mod tests {
|
|||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
panels.begin_frame();
|
panels.begin_frame();
|
||||||
draw_diff(frame, diff, frame.area(), "↑/↓", panels);
|
draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels);
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -2327,7 +2299,7 @@ mod tests {
|
|||||||
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
||||||
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
|
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
|
||||||
}
|
}
|
||||||
assert!(screen_text(&terminal).contains("> A added.txt"));
|
assert!(screen_text(&terminal).contains("> added.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2348,7 +2320,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut diff = diff_comparison(vec![deletion_file(), added]);
|
let mut diff = diff_comparison(vec![deletion_file(), added]);
|
||||||
diff.handle(Input::Down);
|
diff.handle(Input::Down);
|
||||||
diff.handle(Input::Char('\t'));
|
diff.handle(Input::Enter);
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
diff.handle(Input::Right);
|
diff.handle(Input::Right);
|
||||||
}
|
}
|
||||||
@@ -2356,7 +2328,7 @@ mod tests {
|
|||||||
let mut panels = WheelPanels::default();
|
let mut panels = WheelPanels::default();
|
||||||
render_diff(&mut terminal, &diff, &mut panels);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
let text = screen_text(&terminal);
|
let text = screen_text(&terminal);
|
||||||
assert!(text.contains("> R new-name.txt"));
|
assert!(text.contains("> new-name.txt"));
|
||||||
assert!(text.contains("Before · R · old-name.txt"));
|
assert!(text.contains("Before · R · old-name.txt"));
|
||||||
assert!(text.contains("After · R · new-name.txt"));
|
assert!(text.contains("After · R · new-name.txt"));
|
||||||
let geometry = panels.diff.as_ref().unwrap();
|
let geometry = panels.diff.as_ref().unwrap();
|
||||||
@@ -2377,6 +2349,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() {
|
||||||
|
let mut leaf = deletion_file();
|
||||||
|
leaf.path = "chain/a/b/leaf.txt".into();
|
||||||
|
let mut root = deletion_file();
|
||||||
|
root.path = "root.txt".into();
|
||||||
|
let mut diff = diff_comparison(vec![leaf, root]);
|
||||||
|
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 regions = panels.diff.as_ref().unwrap().regions;
|
||||||
|
let navigator = regions[0].0;
|
||||||
|
let before = regions[1].0;
|
||||||
|
let after = regions[2].0;
|
||||||
|
let workspace = Rect::new(0, 1, 120, 16);
|
||||||
|
let (panes, dock) = dock_layout(workspace, DockSide::Right, true);
|
||||||
|
assert_eq!(Some(navigator), dock);
|
||||||
|
assert_eq!(before.x, panes.x);
|
||||||
|
assert_eq!(before.right(), after.x);
|
||||||
|
assert_eq!(after.right(), navigator.x);
|
||||||
|
assert_eq!(navigator.right(), workspace.right());
|
||||||
|
assert_eq!(panels.files.unwrap().0, navigator);
|
||||||
|
let tree_text = |terminal: &Terminal<ratatui::backend::TestBackend>| {
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
(navigator.y..navigator.bottom())
|
||||||
|
.map(|y| {
|
||||||
|
(navigator.x..navigator.right())
|
||||||
|
.map(|x| buffer[(x, y)].symbol())
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
};
|
||||||
|
let text = tree_text(&terminal);
|
||||||
|
assert!(text.contains("Explorer · Tree"));
|
||||||
|
assert!(text.contains("chain/a/b/"));
|
||||||
|
assert!(text.contains("leaf.txt"));
|
||||||
|
assert!(!text.contains("chain/a/b/leaf.txt"));
|
||||||
|
let cursor = terminal.get_cursor_position().unwrap();
|
||||||
|
assert!(navigator.contains(cursor));
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(cursor.x, cursor.y)].bg,
|
||||||
|
Color::Blue
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
|
||||||
|
Color::Cyan
|
||||||
|
);
|
||||||
|
diff.handle(Input::Left);
|
||||||
|
diff.handle(Input::Enter);
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = tree_text(&terminal);
|
||||||
|
assert!(!text.contains("leaf.txt"));
|
||||||
|
assert!(text.contains("chain/a/b/"));
|
||||||
|
assert_eq!(diff.file, 0);
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
assert!(!diff.files.as_ref().unwrap().focused);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
|
||||||
|
Color::DarkGray
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().buffer()[(before.x, before.y)].fg,
|
||||||
|
Color::Cyan
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
@@ -2391,6 +2431,7 @@ mod tests {
|
|||||||
let empty = diff_comparison(vec![]);
|
let empty = diff_comparison(vec![]);
|
||||||
render_diff(&mut terminal, &empty, &mut panels);
|
render_diff(&mut terminal, &empty, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("No changed files"));
|
assert!(screen_text(&terminal).contains("No changed files"));
|
||||||
|
assert!(screen_text(&terminal).contains("Explorer · Tree"));
|
||||||
let binary = diff_comparison(vec![ChangedFile {
|
let binary = diff_comparison(vec![ChangedFile {
|
||||||
path: "image.bin".into(),
|
path: "image.bin".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
@@ -2402,6 +2443,7 @@ mod tests {
|
|||||||
}]);
|
}]);
|
||||||
render_diff(&mut terminal, &binary, &mut panels);
|
render_diff(&mut terminal, &binary, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("Binary file"));
|
assert!(screen_text(&terminal).contains("Binary file"));
|
||||||
|
assert!(screen_text(&terminal).contains("> image.bin"));
|
||||||
let blank = diff_comparison(vec![ChangedFile {
|
let blank = diff_comparison(vec![ChangedFile {
|
||||||
path: "empty.txt".into(),
|
path: "empty.txt".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
@@ -2418,6 +2460,16 @@ mod tests {
|
|||||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||||
for state in [&diff, &empty, &binary, &blank] {
|
for state in [&diff, &empty, &binary, &blank] {
|
||||||
render_diff(&mut tiny, state, &mut panels);
|
render_diff(&mut tiny, state, &mut panels);
|
||||||
|
if let Some(geometry) = &panels.diff {
|
||||||
|
for (rect, _) in geometry.regions {
|
||||||
|
assert!(rect.right() <= width && rect.bottom() <= height);
|
||||||
|
}
|
||||||
|
let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect);
|
||||||
|
assert!(before.right() <= after.x);
|
||||||
|
if navigator.width > 0 {
|
||||||
|
assert!(after.right() <= navigator.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2465,6 +2517,8 @@ mod tests {
|
|||||||
assert!(viewports.document.geometry.is_none());
|
assert!(viewports.document.geometry.is_none());
|
||||||
assert_eq!(editor.lines(), original);
|
assert_eq!(editor.lines(), original);
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
|
assert!(editor.diff().is_some());
|
||||||
|
editor.handle(Input::Escape);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| viewports.draw(frame, &editor))
|
.draw(|frame| viewports.draw(frame, &editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2473,6 +2527,97 @@ mod tests {
|
|||||||
assert_eq!(editor.cursor(), cursor);
|
assert_eq!(editor.cursor(), cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.file("source.txt", &["original"; 60].join("\n"));
|
||||||
|
let git = |args: &[&str]| {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(&directory.0)
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_AUTHOR_NAME", "Diff fixture")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Diff fixture")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||||
|
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||||
|
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.status.success(), "{:?}", output);
|
||||||
|
};
|
||||||
|
git(&["init", "-q"]);
|
||||||
|
for index in 0..8 {
|
||||||
|
directory.file(&format!("file-{index}.txt"), "first\nsecond\nthird\n");
|
||||||
|
}
|
||||||
|
git(&["add", "."]);
|
||||||
|
git(&["commit", "-qm", "base"]);
|
||||||
|
for index in 0..8 {
|
||||||
|
fs::remove_file(directory.0.join(format!("file-{index}.txt"))).unwrap();
|
||||||
|
}
|
||||||
|
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
|
let original = session.editor().lines().to_vec();
|
||||||
|
let cursor = session.editor().cursor();
|
||||||
|
let ready = |session: &mut EditorSession, phase| {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
session.poll();
|
||||||
|
let diff = session.editor().diff().unwrap();
|
||||||
|
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||||
|
if diff.phase == phase && !diff.loading {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(std::time::Instant::now() < deadline, "{diff:?}");
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
session.execute("diff");
|
||||||
|
ready(&mut session, Phase::Before);
|
||||||
|
session.handle(Input::Down);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
ready(&mut session, Phase::View);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||||||
|
let mut viewports = BufferViewports::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||||
|
.unwrap();
|
||||||
|
let regions = viewports.panels.diff.as_ref().unwrap().regions;
|
||||||
|
let tree = regions[0].0;
|
||||||
|
let before = regions[1].0;
|
||||||
|
let after = regions[2].0;
|
||||||
|
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::Files);
|
||||||
|
assert_eq!(diff.files.as_ref().unwrap().selected, 3);
|
||||||
|
assert_eq!(diff.file, 0);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
assert_eq!(session.editor().diff().unwrap().file, 3);
|
||||||
|
assert!(viewports.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
|
||||||
|
));
|
||||||
|
let diff = session.editor().diff().unwrap();
|
||||||
|
assert_eq!(diff.focus, Focus::Before);
|
||||||
|
assert_eq!(diff.top, 2);
|
||||||
|
assert_eq!(diff.file, 3);
|
||||||
|
assert!(viewports.scroll(
|
||||||
|
&mut session,
|
||||||
|
document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1),
|
||||||
|
));
|
||||||
|
let diff = session.editor().diff().unwrap();
|
||||||
|
assert_eq!(diff.focus, Focus::After);
|
||||||
|
assert_eq!(diff.top, 0);
|
||||||
|
assert_eq!(diff.file, 3);
|
||||||
|
assert_eq!(session.editor().lines(), original);
|
||||||
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
|
assert!(viewports.document.geometry.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
|
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
@@ -137,6 +137,17 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
|
|||||||
session.handle(input);
|
session.handle(input);
|
||||||
}
|
}
|
||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.files
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.searching
|
||||||
|
);
|
||||||
|
session.handle(Input::Escape);
|
||||||
assert!(session.editor().diff().is_none());
|
assert!(session.editor().diff().is_none());
|
||||||
assert_eq!(session.editor().context().text, "uafter\n");
|
assert_eq!(session.editor().context().text, "uafter\n");
|
||||||
assert!(session.editor().dirty());
|
assert!(session.editor().dirty());
|
||||||
|
|||||||
Reference in New Issue
Block a user