diff --git a/docs/architecture.md b/docs/architecture.md index d1766ad..61ff14f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. - `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. - `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. diff --git a/docs/configuration.md b/docs/configuration.md index cfb6050..ee98206 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -135,6 +135,8 @@ config.bind "ctrl+f", config.core.right, 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+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. diff --git a/docs/development.md b/docs/development.md index a6af360..e7aeab3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. +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 - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. diff --git a/docs/editing.md b/docs/editing.md index 9e53a17..3fb9fc7 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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. -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. diff --git a/src/diff.rs b/src/diff.rs index 260037e..734f2d6 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,4 +1,5 @@ use crate::editor::Input; +use crate::files::{FilePicker, FileView}; use std::path::PathBuf; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -146,6 +147,7 @@ pub struct DiffState { pub loading: bool, pub error: Option, pub comparison: Option, + pub files: Option, pub file: usize, pub focus: Focus, pub top: usize, @@ -174,6 +176,7 @@ impl DiffState { loading: true, error: None, comparison: None, + files: None, file: 0, focus: Focus::Files, top: 0, @@ -240,6 +243,14 @@ impl DiffState { self.error = None; } 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.phase = Phase::View; self.focus = Focus::Files; @@ -295,18 +306,8 @@ impl DiffState { pub(crate) fn navigate(&mut self, forward: bool) { if self.phase == Phase::View { if self.focus == Focus::Files { - let count = self - .comparison - .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(); + if let Some(files) = &mut self.files { + files.navigate(forward); } } else { self.top = if forward { @@ -334,6 +335,15 @@ impl DiffState { /// Returns true only when the modal should close. All other inputs are consumed. pub(crate) fn handle(&mut self, input: Input) -> bool { 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 { return true; } @@ -424,6 +434,58 @@ impl DiffState { } 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 { Input::Char('\t') => { self.focus = match self.focus { @@ -441,12 +503,6 @@ impl DiffState { } Input::Up | Input::Ctrl('k') => self.navigate(false), 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 { Focus::Before => self.left_before = self.left_before.saturating_sub(1), Focus::After => self.left_after = self.left_after.saturating_sub(1), @@ -462,25 +518,17 @@ impl DiffState { Focus::Files => {} }, Input::Home | Input::End => { - if self.focus == Focus::Files { - self.file = if input == Input::Home { - 0 - } else { - self.comparison - .as_ref() - .map_or(0, |comparison| comparison.files.len().saturating_sub(1)) - }; - self.align_file(); + self.top = if input == Input::Home { + 0 } else { - self.top = if input == Input::Home { - 0 - } else { - self.rows.len().saturating_sub(1) - }; - } + self.rows.len().saturating_sub(1) + }; } _ => {} } + if let Some(files) = &mut self.files { + files.focused = self.focus == Focus::Files; + } } fn align_file(&mut self) { @@ -859,6 +907,107 @@ mod tests { 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] fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() { let mut editor = editor(); @@ -901,6 +1050,8 @@ mod tests { assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor.handle(Input::BackTab); editor.handle(Input::Down); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Enter); let state = editor.diff().unwrap(); assert_eq!(state.file, 1); assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0)); @@ -910,7 +1061,9 @@ mod tests { .iter() .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!(editor.diff().unwrap().rows.is_empty()); assert!( @@ -918,7 +1071,9 @@ mod tests { .notice .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); } @@ -1016,6 +1171,7 @@ mod tests { assert_eq!(editor.context().text, original); assert_eq!(editor.carets(), carets); editor.handle(Input::Escape); + editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "original\\nsecond"); } diff --git a/src/editor.rs b/src/editor.rs index 7648144..600e76b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1281,6 +1281,9 @@ 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) { + if self.handle_tree_binding(&input) { + return; + } match self.selection_bindings.get(&input) { Some(Binding::Action(Action::Down)) => { self.diff.as_mut().expect("active diff").navigate(true); @@ -2544,7 +2547,11 @@ impl Editor { } 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 { if picker.searching { @@ -2612,10 +2619,12 @@ impl Editor { } fn handle_tree_binding(&mut self, input: &Input) -> bool { - let Some(picker) = self - .file_picker - .as_mut() - .filter(|picker| picker.focused && picker.view == FileView::Tree) + let picker = if let Some(diff) = &mut self.diff { + diff.files.as_mut() + } else { + self.file_picker.as_mut() + }; + let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree) else { return false; }; @@ -2897,19 +2906,7 @@ impl Editor { } Input::Ctrl('r') => picker.refresh(), Input::Ctrl('t') => picker.toggle_view(), - Input::Ctrl('u') => picker.clear_filter(), - 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(); - } + _ if picker.handle_filter_input(&input) => {} Input::Enter => { let Some(path) = picker.accept() else { return }; let source = self.jump_location(); diff --git a/src/files.rs b/src/files.rs index a21239f..affdd17 100644 --- a/src/files.rs +++ b/src/files.rs @@ -43,6 +43,7 @@ pub struct FileRow<'a> { pub expanded: bool, } +#[derive(Debug)] struct Node { path: PathBuf, label: String, @@ -52,6 +53,7 @@ struct Node { children: Vec, } +#[derive(Debug)] struct TreeNode { node: usize, label: String, @@ -59,6 +61,7 @@ struct TreeNode { children: Vec, } +#[derive(Debug)] struct VisibleRow { node: usize, tree: Option, @@ -66,6 +69,7 @@ struct VisibleRow { expanded: bool, } +#[derive(Debug)] pub struct FilePicker { pub root: PathBuf, pub query: String, @@ -154,10 +158,51 @@ impl FilePicker { 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) -> 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, + presentation: FilePresentation, + view: FileView, + warning: Option, + ) -> Self { nodes.sort_by(|left, right| left.path.cmp(&right.path)); let by_path: HashMap<_, _> = nodes .iter() .enumerate() + .filter(|(_, node)| node.is_dir) .map(|(index, node)| (node.path.clone(), index)) .collect(); let mut roots = Vec::new(); @@ -223,7 +268,7 @@ impl FilePicker { } picker.project(None, false); picker.remember_selection(false); - Ok(picker) + picker } fn compact(&mut self, start: usize, parent: Option) -> 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) { if self.view == view { return; @@ -699,6 +775,29 @@ impl FilePicker { #[cfg(test)] mod tests { 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::>(), + ["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}; static TEMP_ID: AtomicU64 = AtomicU64::new(0); diff --git a/src/tui.rs b/src/tui.rs index af70654..6e533e1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -422,6 +422,7 @@ fn draw( diff, area, editor.selection_navigation_hint(), + editor.file_navigation_hint(), panels, ); return; @@ -907,6 +908,7 @@ fn draw_diff( diff: &DiffState, area: Rect, navigation_hint: &str, + file_navigation_hint: &str, panels: &mut WheelPanels, ) { if area.width == 0 || area.height == 0 { @@ -1138,59 +1140,17 @@ fn draw_diff( notice_area, ); } - let navigator_width = if workspace.width >= 40 { - (workspace.width / 4).min(32) + let (panes, navigator) = if diff.files.is_some() { + dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files) } else { - 0 + (workspace, None) }; - let [navigator, panes] = - Layout::horizontal([Constraint::Length(navigator_width), Constraint::Fill(1)]) - .areas(workspace); let [before_area, after_area] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes); - let navigator_inner = diff_panel( - frame, - navigator, - " Changed files ", - diff.focus == Focus::Files, - ); - if let Some(comparison) = comparison - && navigator_inner.width > 0 - && navigator_inner.height > 0 + if let Some(picker) = &diff.files + && let Some(navigator) = navigator { - let height = usize::from(navigator_inner.height); - let first = diff.file.saturating_sub(height.saturating_sub(1)); - let lines: Vec = 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); + draw_file_list(frame, picker, navigator, file_navigation_hint, panels); } if let Some(file) = selected { draw_diff_pane(frame, diff, file, before_area, true); @@ -1207,14 +1167,14 @@ fn draw_diff( } frame.render_widget( 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)), help, ); panels.diff = Some(DiffGeometry { regions: [ - (navigator, Focus::Files), + (navigator.unwrap_or_default(), Focus::Files), (before_area, Focus::Before), (after_area, Focus::After), ], @@ -1811,7 +1771,7 @@ fn draw_file_list( if bordered { 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_help = content.height >= 5 + u16::from(show_error); 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 controls = 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", + let controls = if picker.root.as_os_str().is_empty() { + if browsing { + "Enter · i filter · Esc close · Tab panes · ^T view" + } else { + "Enter · Esc · Tab panes · ^T view" + } + } 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}") + } else if picker.root.as_os_str().is_empty() { + "Editor focus · Tab to browse changed files".into() } else { "Editor focus · invoke explorer to browse".into() }; @@ -2212,7 +2184,7 @@ mod tests { terminal .draw(|frame| { panels.begin_frame(); - draw_diff(frame, diff, frame.area(), "↑/↓", panels); + draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels); }) .unwrap(); } @@ -2327,7 +2299,7 @@ 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("> A added.txt")); + assert!(screen_text(&terminal).contains("> added.txt")); } #[test] @@ -2348,7 +2320,7 @@ mod tests { }; let mut diff = diff_comparison(vec![deletion_file(), added]); diff.handle(Input::Down); - diff.handle(Input::Char('\t')); + diff.handle(Input::Enter); for _ in 0..4 { diff.handle(Input::Right); } @@ -2356,7 +2328,7 @@ mod tests { let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); 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("After · R · new-name.txt")); 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| { + let buffer = terminal.backend().buffer(); + (navigator.y..navigator.bottom()) + .map(|y| { + (navigator.x..navigator.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .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] fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); @@ -2391,6 +2431,7 @@ mod tests { let empty = diff_comparison(vec![]); render_diff(&mut terminal, &empty, &mut panels); assert!(screen_text(&terminal).contains("No changed files")); + assert!(screen_text(&terminal).contains("Explorer · Tree")); let binary = diff_comparison(vec![ChangedFile { path: "image.bin".into(), old_path: None, @@ -2402,6 +2443,7 @@ 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, @@ -2418,6 +2460,16 @@ mod tests { Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); for state in [&diff, &empty, &binary, &blank] { 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_eq!(editor.lines(), original); editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); @@ -2473,6 +2527,97 @@ mod tests { 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] fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() { let directory = Directory::new(); diff --git a/tests/diff.rs b/tests/diff.rs index 99f666c..cfdece0 100644 --- a/tests/diff.rs +++ b/tests/diff.rs @@ -137,6 +137,17 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() { session.handle(input); } 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_eq!(session.editor().context().text, "uafter\n"); assert!(session.editor().dirty());