From 168e12160a6da5629adfcf48a7a9b340522dba8e Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 09:53:45 +0200 Subject: [PATCH] refactor: route conflict resolution through the diff workspace Use the active DiffState, native FilePicker and existing diff input, layout, focus, source navigation and scrolling paths for Resolution. Delete separate resolver filtering, file-list rendering and scroll APIs. Keep projections, source choices, History and guarded jj saves distinct. Verify native parity for picker remaps, current-file selection, excerpt initialization, folded search, wheel focus, layouts, buffer transitions and per-file viewports. All 932 tests pass; actual terminal smoke covers Space-e explorer, Down/Enter file selection and guarded confirmed save. Clippy completes with existing warnings only. Size exception: 1197 implementation added/deleted lines excluding tests and docs; input routing, native buffer lifecycle and rendering are kept as one coherent cutover, without a parallel legacy resolver path. --- docs/architecture.md | 2 + docs/development.md | 2 + docs/editing.md | 4 +- src/diff.rs | 150 +++++-- src/editor.rs | 946 ++++++++++++++++++++++++++++--------------- src/session.rs | 19 - src/tui.rs | 908 ++++++++++++++++++++++------------------- tests/resolve.rs | 2 +- 8 files changed, 1231 insertions(+), 802 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4ef26cd..bf9354d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,8 @@ Each comparison owns its split/unified mode and split axis; startup defaults app The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards. +Resolution exposes its projected comparison through the same active DiffState and native FilePicker as ordinary diffs. Pane labels and writability are explicit comparison properties; file selection, bindings, native navigation, layout and scrolling share the existing interaction path. Conflict state owns only projections, source choices and guarded save/check metadata; there is no separate resolver filter or file-list renderer. + `Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. diff --git a/docs/development.md b/docs/development.md index 97d41ff..8fb545d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,6 +60,8 @@ Conflicted-revision regressions preserve unchanged symlinks, defer oversized uns Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories. +Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path. + Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. diff --git a/docs/editing.md b/docs/editing.md index 8e0c5c0..4927d97 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -473,9 +473,9 @@ Run **`:resolve`** in Normal mode for the current jj working copy. It opens a de | `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views | | `resolve.close` | Close the resolver without discarding dirty drafts | -**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. The file explorer is hidden by default and available on demand. Narrow Resolution panes retain side-by-side comparison where usable; narrow History shows the focused input. Native editing, copying, search and remapped pane navigation work normally; read-only Alternatives and History reject edits and saves. +**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Narrow History shows the focused input. Read-only Alternatives and History reject edits and saves. -Input indexes are one-based and include bases. Commands appear in the palette and through corresponding `config.core.resolve` actions; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and an open file list. In the list, `/` filters and Enter opens a file. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”. +Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”. **`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes. diff --git a/src/diff.rs b/src/diff.rs index 0bc7879..a9133b2 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -258,6 +258,8 @@ pub struct DiffState { pub view_mode: ViewMode, pub comparison_mode: ComparisonMode, pub split_axis: crate::panes::SplitAxis, + pub writable_side: Option, + pub side_labels: [&'static str; 2], pub before: Option, pub after: Option, pub category: usize, @@ -272,6 +274,8 @@ pub struct DiffState { pub files: Option, pub files_visible: bool, pub file: usize, + external_file_selection: bool, + pub(crate) requested_file: Option, pub focus: Focus, pub top: usize, pub left_before: usize, @@ -310,6 +314,8 @@ impl DiffState { view_mode: ViewMode::Split, comparison_mode: ComparisonMode::default(), split_axis: crate::panes::SplitAxis::Vertical, + writable_side: Some(Focus::After), + side_labels: ["Before", "After"], before: None, after: None, category: 0, @@ -324,6 +330,8 @@ impl DiffState { snapshot_roots: [None, None], files_visible: true, file: 0, + external_file_selection: false, + requested_file: None, focus: Focus::Files, top: 0, left_before: 0, @@ -349,6 +357,42 @@ impl DiffState { after_widths: Vec::new(), } } + pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec, file: usize) { + let comparison = self.comparison.get_or_insert_with(|| Comparison { + before: "Draft".into(), + after: "Alternative".into(), + files: Vec::new(), + root: root.clone(), + editable_paths: BTreeSet::new(), + }); + comparison.root = root.clone(); + comparison.files.resize_with(paths.len(), || ChangedFile { + path: PathBuf::new(), + old_path: None, + status: FileStatus::Modified, + loaded: true, + before: Vec::new(), + after: Vec::new(), + before_eof: false, + after_eof: false, + hunks: Vec::new(), + notice: None, + }); + for (entry, path) in comparison.files.iter_mut().zip(&paths) { + entry.path.clone_from(path); + } + self.file = file; + self.external_file_selection = true; + self.requested_file = None; + if self.files.is_none() { + self.files = Some(FilePicker::from_paths(root, paths.into_iter().enumerate())); + } else if let Some(files) = &mut self.files { + files.replace_paths(paths.into_iter().enumerate()); + } + if let Some(files) = &mut self.files { + files.reveal_path(&comparison.files[file].path); + } + } pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) { fn content(lines: &[String]) -> (&[String], bool) { @@ -369,47 +413,34 @@ impl DiffState { } let (before, before_eof) = content(draft); let (after, after_eof) = content(alternative); - if self.comparison.as_ref().is_some_and(|comparison| { - let file = &comparison.files[0]; - file.before == before - && file.after == after - && file.before_eof == before_eof - && file.after_eof == after_eof - }) { + if self.phase == Phase::View + && self.comparison.as_ref().is_some_and(|comparison| { + let file = &comparison.files[self.file]; + file.before == before + && file.after == after + && file.before_eof == before_eof + && file.after_eof == after_eof + }) + { return; } let viewport = (self.top, self.left_before, self.left_after); + if self.comparison.is_none() { + self.set_resolver_paths(PathBuf::new(), vec![PathBuf::from("resolution.txt")], 0); + } self.phase = Phase::View; self.loading = false; self.pending = None; self.request_kind = None; - self.files_visible = false; + self.side_labels = ["Draft", "Alternative"]; self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1)); - self.file = 0; self.live_files.clear(); - self.comparison = Some(Comparison { - before: "Draft".into(), - after: "Alternative".into(), - root: PathBuf::new(), - editable_paths: BTreeSet::new(), - files: vec![ChangedFile { - path: PathBuf::new(), - old_path: None, - status: FileStatus::Modified, - loaded: true, - before: before.to_vec(), - after: after.to_vec(), - before_eof, - after_eof, - hunks: line_hunks(before, before_eof, after, after_eof), - notice: None, - }], - }); - self.focus = if self.focus == Focus::After { - Focus::After - } else { - Focus::Before - }; + let file = &mut self.comparison.as_mut().unwrap().files[self.file]; + file.before = before.to_vec(); + file.after = after.to_vec(); + file.before_eof = before_eof; + file.after_eof = after_eof; + file.hunks = line_hunks(before, before_eof, after, after_eof); self.align_file(); let before_terminal = (draft.len() > before.len()).then_some(before.len()); let after_terminal = (alternative.len() > after.len()).then_some(after.len()); @@ -422,7 +453,10 @@ impl DiffState { }); self.reset_excerpts(); } - if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() { + if self.comparison.as_ref().unwrap().files[self.file] + .hunks + .is_empty() + { self.excerpt_ranges = vec![(0, self.rows.len())]; self.rebuild_display_rows(); } @@ -1151,7 +1185,9 @@ impl DiffState { if let Some(path) = files.accept() && self.select_file(&path) { - self.focus = Focus::After; + self.focus = self + .writable_side + .unwrap_or_else(|| self.first_text_focus()); self.normalize_focus(); let files = self.files.as_mut().expect("comparison picker"); files.focused = false; @@ -1351,6 +1387,18 @@ impl DiffState { } } } + pub fn side_label(&self, focus: Focus) -> &'static str { + self.side_labels[usize::from(focus == Focus::After)] + } + + pub fn side_editable(&self, focus: Focus) -> bool { + self.writable_side == Some(focus) + && match focus { + Focus::Before => self.selected_file_loaded(), + Focus::After => self.after_editable(), + Focus::Files => false, + } + } pub fn after_editable(&self) -> bool { matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) @@ -1435,6 +1483,10 @@ impl DiffState { if let Some(files) = &mut self.files { files.reveal_path(&file.path); } + if self.external_file_selection { + self.requested_file = Some(index); + return true; + } self.file = index; self.normalize_focus(); self.live_buffer = None; @@ -2381,6 +2433,11 @@ mod tests { state.set_resolver_text(&lines, &lines); assert_eq!(state.pane_layout(), PaneLayout::Split); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!( + state.comparison.as_ref().unwrap().files[0].path, + Path::new("resolution.txt") + ); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); for row in 0..lines.len() { assert!(state.source_display_row(Focus::Before, row).is_some()); assert!(state.source_display_row(Focus::After, row).is_some()); @@ -2395,6 +2452,31 @@ mod tests { } } + #[test] + fn resolver_catalog_reveals_the_current_native_file() { + let mut state = DiffState::new(); + state.set_resolver_paths( + PathBuf::new(), + vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], + 1, + ); + let files = state.files.as_ref().unwrap(); + assert_eq!( + files.entries().nth(files.selected).unwrap().path, + Path::new("first.txt") + ); + state.set_resolver_paths( + PathBuf::new(), + vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], + 2, + ); + let files = state.files.as_ref().unwrap(); + assert_eq!( + files.entries().nth(files.selected).unwrap().path, + Path::new("second.txt") + ); + } + fn excerpt_state(len: usize, changed: &[usize]) -> DiffState { let mut state = DiffState::new(); state.phase = Phase::View; diff --git a/src/editor.rs b/src/editor.rs index 9909763..76021e5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1494,7 +1494,6 @@ pub struct ResolverFile { pub struct ResolverWorkspace { pub files: Vec, - pub selected_file: usize, pub current_file: usize, pub selected_source: usize, pub draft_pane: usize, @@ -1503,11 +1502,9 @@ pub struct ResolverWorkspace { pub view: ResolverView, pub history_sources: [usize; 2], pub selected_base: Option, - pub files_visible: bool, - pub files_focused: bool, + history_bounds: Vec<(usize, PaneRect)>, + files_bounds: Option, pub loading: bool, - pub filter: String, - pub filtering: bool, pub error: Option, model: Option, preferred_path: Option, @@ -2187,17 +2184,22 @@ impl Editor { return; } self.finish_pane_interaction(); - self.resolvers.get_mut(&self.tab_id).unwrap().files_focused = false; + let focus = if pane == self.resolvers[&self.tab_id].draft_pane { + Focus::Before + } else { + Focus::After + }; + if let Some(diff) = self.resolver_diff_mut() { + diff.focus = focus; + } + self.blur_file_picker(); self.select_pane(pane); self.refresh_resolver_comparison(); } pub fn focus_resolver_files(&mut self) { self.finish_pane_interaction(); - if let Some(resolver) = self.resolvers.get_mut(&self.tab_id) { - resolver.files_visible = true; - resolver.files_focused = true; - } + self.open_file_picker(FilePresentation::Docked); } fn open_resolver(&mut self) { @@ -2229,7 +2231,6 @@ impl Editor { self.tab_id, ResolverWorkspace { files: Vec::new(), - selected_file: 0, current_file: 0, selected_source: 0, draft_pane, @@ -2238,11 +2239,9 @@ impl Editor { view: ResolverView::Resolution, history_sources: [0, 0], selected_base: None, - files_visible: false, - files_focused: false, + history_bounds: Vec::new(), + files_bounds: None, loading: true, - filter: String::new(), - filtering: false, error: None, model: None, preferred_path, @@ -2440,7 +2439,6 @@ impl Editor { } let resolver = self.resolvers.get_mut(&tab).unwrap(); resolver.files = files; - resolver.selected_file = selected; resolver.loading = false; resolver.model = Some(model); let previous_tab = self.tab_id; @@ -2561,12 +2559,16 @@ impl Editor { .collect(); let base = selected.sources.iter().position(|source| source.base); let draft_pane = resolver.draft_pane; + let previous_file = resolver.current_file; let old: Vec<_> = resolver .history_panes .iter() .copied() .chain(resolver.alternative_pane) .collect(); + let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); + resolver.alternative_pane = None; + resolver.history_panes.clear(); self.finish_pane_interaction(); self.select_pane(draft_pane); self.switch_buffer(draft); @@ -2582,7 +2584,6 @@ impl Editor { } let alternative_pane = self.resolver_readonly_pane("", draft_pane); let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); - resolver.selected_file = file; resolver.current_file = file; resolver.selected_source = side_indices .iter() @@ -2596,24 +2597,48 @@ impl Editor { resolver.selected_base = base; resolver.history_panes = history_panes; resolver.alternative_pane = Some(alternative_pane); - resolver.files[file].comparison_key = None; - resolver.files_focused = false; - self.refresh_resolver_comparison(); - if first_open - && let Some(row) = self - .resolver_diff() - .and_then(|diff| diff.comparison.as_ref()) - .and_then(|comparison| comparison.files[0].hunks.first()) - .map(|hunk| hunk.before_start) - { - self.active.view.targets = vec![Target::caret(Cursor { - row: row.min(self.active.lines.len() - 1), - column: 0, - })]; - self.active.view.primary = 0; - self.reveal_caret(); - self.refresh_resolver_comparison(); + let picker = resolver.files[previous_file].diff.files.take(); + let visible = resolver.files[previous_file].diff.files_visible; + let previous_layout = { + let diff = &resolver.files[previous_file].diff; + (diff.phase == Phase::View).then_some((diff.view_mode, diff.split_axis, diff.word_wrap)) + }; + let reviewed = std::mem::take(&mut resolver.files[previous_file].diff.reviewed_files); + let had_picker = picker.is_some(); + if let Some(picker) = picker { + resolver.files[file].diff.files = Some(picker); + resolver.files[file].diff.files_visible = visible; } + resolver.files[file].diff.reviewed_files = reviewed; + if let Some((view, axis, wrap)) = previous_layout { + resolver.files[file].diff.set_view_mode(view); + resolver.files[file].diff.split_axis = axis; + resolver.files[file].diff.word_wrap = wrap; + } + let root = resolver + .model + .as_ref() + .map_or_else(|| self.project_root.clone(), |model| model.root.clone()); + let paths = resolver + .files + .iter() + .map(|file| file.path.clone()) + .collect(); + resolver.files[file] + .diff + .set_resolver_paths(root, paths, file); + if first_open && !had_picker { + resolver.files[file] + .diff + .set_view_mode(self.diff_display_options.0); + resolver.files[file].diff.split_axis = self.diff_display_options.1; + resolver.files[file].diff.files_visible = false; + } + resolver.files[file].diff.writable_side = + resolver.files[file].text.then_some(Focus::Before); + resolver.files[file].comparison_key = None; + self.blur_file_picker(); + self.refresh_resolver_comparison(); if self.resolver().unwrap().view == ResolverView::History { self.set_resolver_view(ResolverView::History); } @@ -2670,7 +2695,6 @@ impl Editor { return; } resolver.view = view; - resolver.files_focused = false; let pane = match view { ResolverView::Resolution => Some(resolver.draft_pane), ResolverView::History => resolver @@ -2678,6 +2702,7 @@ impl Editor { .get(resolver.history_sources[0]) .copied(), }; + self.blur_file_picker(); if let Some(pane) = pane { self.focus_resolver_pane(pane); } @@ -2685,24 +2710,36 @@ impl Editor { pub fn resolver_diff(&self) -> Option<&DiffState> { let resolver = self.resolver()?; - (resolver.view == ResolverView::Resolution) - .then(|| { - resolver - .files - .get(resolver.current_file) - .map(|file| &file.diff) - }) - .flatten() + let alternative = resolver.alternative_pane?; + if resolver.view != ResolverView::Resolution || !self.panes.contains_key(&alternative) { + return None; + } + self.resolver_workspace_diff().filter(|diff| { + diff.phase == Phase::View + && diff.file == resolver.current_file + && diff.selected_file_loaded() + }) + } + + fn resolver_workspace_diff(&self) -> Option<&DiffState> { + let resolver = self.resolver()?; + resolver + .files + .get(resolver.current_file) + .map(|file| &file.diff) } pub fn resolver_buffer_focus(&self) -> Option { let resolver = self.resolver()?; - if resolver.view != ResolverView::Resolution || resolver.files_focused { - None - } else if self.pane_id == resolver.draft_pane { - Some(Focus::Before) + self.resolver_diff()?; + if self.file_picker().is_some_and(|picker| picker.focused) { + return None; + } + if self.pane_id == resolver.draft_pane { + (resolver.files[resolver.current_file].draft_buffer == self.active.id) + .then_some(Focus::Before) } else if Some(self.pane_id) == resolver.alternative_pane { - Some(Focus::After) + (self.pane_buffer_id(self.pane_id) == Some(self.active.id)).then_some(Focus::After) } else { None } @@ -2728,6 +2765,7 @@ impl Editor { return; }; let draft_buffer = file.draft_buffer; + let writable = file.text && file.whole_file_choice.is_none(); let alternative_buffer = self.panes[&alternative_pane].buffer_id; let source = resolver.selected_source; let Some(buffer) = self.buffer(draft_buffer) else { @@ -2735,6 +2773,7 @@ impl Editor { }; let key = (buffer.text_revision, source); let changed = file.comparison_key != Some(key); + let first_comparison = file.diff.comparison_id.is_none(); if changed { if file.comparison_key.is_none_or(|(_, index)| index != source) { let alternative = match &file.projection { @@ -2795,6 +2834,23 @@ impl Editor { } file.comparison_key = Some(key); } + if let Some(diff) = self.resolver_diff_mut() { + diff.writable_side = writable.then_some(Focus::Before); + } + if first_comparison + && self.resolver_buffer_focus() == Some(Focus::Before) + && let Some(row) = self + .resolver_diff() + .and_then(|diff| diff.comparison.as_ref()?.files[diff.file].hunks.first()) + .map(|hunk| hunk.before_start) + { + self.active.view.targets = vec![Target::caret(Cursor { + row: row.min(self.active.lines.len() - 1), + column: 0, + })]; + self.active.view.primary = 0; + self.reveal_caret(); + } let focus = self.resolver_buffer_focus(); let row = self.cursor().row; let follow = self.active.view.caret_reveal_revision; @@ -2814,43 +2870,6 @@ impl Editor { } } - pub(crate) fn set_resolver_viewport( - &mut self, - top: usize, - left_before: usize, - left_after: usize, - ) { - if let Some(diff) = self.resolver_diff_mut() { - diff.top = top; - diff.left_before = left_before; - diff.left_after = left_after; - diff.follow_caret = false; - } - } - - pub(crate) fn scroll_resolver_diff(&mut self, _focus: Focus, down: bool) { - if let Some(diff) = self.resolver_diff_mut() { - diff.top = if down { - diff.top.saturating_add(1) - } else { - diff.top.saturating_sub(1) - } - .min(diff.display_rows.len().saturating_sub(1)); - diff.follow_caret = false; - } - } - - pub(crate) fn scroll_resolver_diff_horizontal( - &mut self, - focus: Focus, - right: bool, - width: usize, - ) { - if let Some(diff) = self.resolver_diff_mut() { - diff.scroll_horizontal(focus, right, width); - } - } - fn resolver_decision(&mut self, forward: bool) { let Some(resolver) = self.resolver() else { return; @@ -2862,7 +2881,7 @@ impl Editor { let Some(diff) = self.resolver_diff() else { return; }; - let hunks = &diff.comparison.as_ref().unwrap().files[0].hunks; + let hunks = &diff.comparison.as_ref().unwrap().files[diff.file].hunks; let hunk = if forward { hunks .iter() @@ -2939,10 +2958,8 @@ impl Editor { }) } Action::ResolveFiles => { - if resolver.files_visible { - let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); - resolver.files_visible = false; - resolver.files_focused = false; + if self.file_picker().is_some() { + self.close_file_picker(); } else { self.focus_resolver_files(); } @@ -3060,15 +3077,18 @@ impl Editor { self.resolver_diff() .and_then(|diff| diff.comparison.as_ref()) .and_then(|comparison| { - comparison.files[0].hunks.iter().find(|hunk| { - let row = self.cursor().row; - if hunk.after_len == 0 { - hunk.after_start == row - } else { - (hunk.after_start..hunk.after_start + hunk.after_len) - .contains(&row) - } - }) + comparison.files[self.resolver_diff().unwrap().file] + .hunks + .iter() + .find(|hunk| { + let row = self.cursor().row; + if hunk.after_len == 0 { + hunk.after_start == row + } else { + (hunk.after_start..hunk.after_start + hunk.after_len) + .contains(&row) + } + }) }) .map(|hunk| hunk.before_start) }) @@ -3095,7 +3115,7 @@ impl Editor { self.refresh_resolver_comparison(); let row = self.cursor().row; let diff = self.resolver_diff().unwrap(); - let comparison = &diff.comparison.as_ref().unwrap().files[0]; + let comparison = &diff.comparison.as_ref().unwrap().files[diff.file]; let Some(hunk) = comparison.hunks.iter().find(|hunk| { if hunk.before_len == 0 { hunk.before_start == row @@ -3225,126 +3245,48 @@ impl Editor { }) } - fn handle_resolver_input(&mut self, input: &Input) -> bool { - let Some(resolver) = self.resolver() else { + fn handle_resolver_history_focus(&mut self, input: &Input) -> bool { + let Some(_) = self + .resolver() + .filter(|resolver| resolver.view == ResolverView::History) + else { return false; }; if self.palette.is_some() - || self.startup_diagnostic.is_some() || self.active.view.mode != Mode::Normal + || self.sequence_pending().is_some() || self.seek_pending.is_some() || self.line_pending.is_some() + || self.count.is_some() + || !matches!(input, Input::Char('\t') | Input::BackTab) { return false; } - if !resolver.files_focused - && resolver.view == ResolverView::Resolution - && self.pending_sequence.keys.is_empty() - && self.pending_sequence.prefix.is_none() - && self.count.is_none() - && matches!(input, Input::Char('[' | ']')) - { - let focus = self.resolver_buffer_focus().unwrap_or(Focus::Before); - let row = self.cursor().row; - if let Some(diff) = self.resolver_diff_mut() { - diff.expand_source_excerpt(focus, *input == Input::Char(']'), row); - } - return true; - } - if matches!(input, Input::Char('\t') | Input::BackTab) - && self.pending_sequence.keys.is_empty() - && self.pending_sequence.prefix.is_none() - { - let panes = self.resolver_visible_panes(); - let current = if resolver.files_focused { - panes.len() - } else { - panes - .iter() - .position(|pane| *pane == self.pane_id) - .unwrap_or(0) - }; - let count = panes.len() + usize::from(resolver.files_visible); - if count == 0 { - return true; - } - let next = (current - + if *input == Input::BackTab { - count - 1 - } else { - 1 - }) - % count; - if let Some(pane) = panes.get(next) { - self.focus_resolver_pane(*pane); - } else { - self.focus_resolver_files(); - } - return true; - } - if !resolver.files_focused { + let panes = self.resolver_visible_panes(); + let files = self.file_picker().is_some(); + let current = if self.file_picker().is_some_and(|picker| picker.focused) { + panes.len() + } else { + panes + .iter() + .position(|pane| *pane == self.pane_id) + .unwrap_or(0) + }; + let count = panes.len() + usize::from(files); + if count == 0 { return false; } - if resolver.filtering { - match input { - Input::Escape | Input::Enter => { - self.resolvers.get_mut(&self.tab_id).unwrap().filtering = false - } - Input::Backspace => { - BackspaceKind::Grapheme - .truncate(&mut self.resolvers.get_mut(&self.tab_id).unwrap().filter); - } - Input::Char(ch) if !ch.is_control() => self - .resolvers - .get_mut(&self.tab_id) - .unwrap() - .filter - .push(*ch), - _ => return true, - } - return true; - } - match input { - Input::Char('/') => self.resolvers.get_mut(&self.tab_id).unwrap().filtering = true, - Input::Escape => { - let pane = self.resolver_visible_panes().first().copied(); - if let Some(pane) = pane { - self.focus_resolver_pane(pane) - } - } - Input::Enter => self.select_resolver_file(resolver.selected_file), - Input::Char('j') | Input::Down | Input::Char('k') | Input::Up => { - let forward = matches!(input, Input::Char('j') | Input::Down); - let visible: Vec<_> = resolver - .files - .iter() - .enumerate() - .filter(|(_, file)| { - file.path - .to_string_lossy() - .to_lowercase() - .contains(&resolver.filter.to_lowercase()) - }) - .map(|(index, _)| index) - .collect(); - if !visible.is_empty() { - let position = visible - .iter() - .position(|index| *index == resolver.selected_file) - .unwrap_or(0); - let next = - (position + if forward { 1 } else { visible.len() - 1 }) % visible.len(); - self.resolvers.get_mut(&self.tab_id).unwrap().selected_file = visible[next]; - } - } - _ => { - if matches!( - input, - Input::Char(':') | Input::Ctrl('p') | Input::Alt(_) | Input::CtrlAlt(_) - ) { - return false; - } - } + let next = (current + + if *input == Input::BackTab { + count - 1 + } else { + 1 + }) + % count; + if let Some(pane) = panes.get(next) { + self.focus_resolver_pane(*pane); + } else { + self.focus_resolver_files(); } true } @@ -3493,6 +3435,18 @@ impl Editor { pub fn set_diff_pane_bounds(&mut self, bounds: [(Focus, PaneRect); 3]) { self.diff_pane_bounds = bounds; } + pub(crate) fn set_resolver_history_bounds( + &mut self, + panes: impl IntoIterator, + files: Option, + ) { + if let Some(resolver) = self.resolvers.get_mut(&self.tab_id) { + resolver.history_bounds.clear(); + resolver.history_bounds.extend(panes); + resolver.files_bounds = files; + } + } + pub fn pane_buffer_id(&self, id: usize) -> Option { self.panes.get(&id).map(|pane| pane.buffer_id) } @@ -3628,9 +3582,7 @@ impl Editor { let focused = self.render_pane_focused; let is_focused = focused && self.pane_id == id - && !self - .resolver() - .is_some_and(|resolver| resolver.files_focused); + && !self.file_picker().is_some_and(|picker| picker.focused); self.render_pane_focused = is_focused; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.with_pane(id, |editor| callback(editor)) @@ -3682,32 +3634,51 @@ impl Editor { } fn focus_pane(&mut self, direction: PaneDirection) { - if let Some(resolver) = self.resolver() { - let panes = self.resolver_visible_panes(); - let current = panes - .iter() - .position(|pane| *pane == self.pane_id) - .unwrap_or(0); - let pane = match direction { - PaneDirection::Right if resolver.files_focused => None, - PaneDirection::Left if resolver.files_focused => panes.last().copied(), - PaneDirection::Right if current + 1 == panes.len() && resolver.files_visible => { - self.focus_resolver_files(); - return; - } - PaneDirection::Right => panes.get(current + 1).copied(), - PaneDirection::Left => current - .checked_sub(1) - .and_then(|index| panes.get(index)) - .copied(), - PaneDirection::Up | PaneDirection::Down => None, + if self + .resolver() + .is_some_and(|resolver| resolver.view == ResolverView::History) + { + let resolver = self.resolver().unwrap(); + let files_focused = self.file_picker().is_some_and(|picker| picker.focused); + let current = if files_focused { + resolver.files_bounds + } else { + resolver + .history_bounds + .iter() + .find(|(pane, _)| *pane == self.pane_id) + .map(|(_, rect)| *rect) }; - if let Some(pane) = pane { - self.focus_resolver_pane(pane) + let Some(current) = current else { return }; + let files = resolver.history_bounds.len(); + let neighbor = directional_neighbor( + current, + direction, + resolver + .history_bounds + .iter() + .enumerate() + .filter_map(|(index, (pane, rect))| { + (files_focused || *pane != self.pane_id).then_some((index, *rect)) + }) + .chain( + resolver + .files_bounds + .filter(|_| !files_focused) + .map(|rect| (files, rect)), + ), + ); + if let Some(neighbor) = neighbor { + if neighbor == files { + self.focus_resolver_files(); + } else { + let pane = resolver.history_bounds[neighbor].0; + self.focus_resolver_pane(pane); + } } return; } - if let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) { + if let Some(diff) = self.diff().filter(|diff| diff.phase == Phase::View) { let focus = diff.focus; if let Some((_, current)) = self .diff_pane_bounds @@ -3727,8 +3698,9 @@ impl Editor { ); if let Some(neighbor) = neighbor { self.finish_pane_interaction(); - let diff = self.diff.as_mut().expect("comparison stays open"); - diff.focus = self.diff_pane_bounds[neighbor].0; + let focus = self.diff_pane_bounds[neighbor].0; + let diff = self.interaction_diff_mut().expect("comparison stays open"); + diff.focus = focus; if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } @@ -3737,6 +3709,9 @@ impl Editor { } } } + if self.resolver().is_some() { + return; + } if let Some(id) = self .pane_layout .neighbor(self.pane_id, direction, self.pane_bounds) @@ -4015,7 +3990,30 @@ impl Editor { } pub fn diff(&self) -> Option<&DiffState> { - self.diff.as_ref() + self.resolver_diff().or(self.diff.as_ref()) + } + + fn interaction_diff_mut(&mut self) -> Option<&mut DiffState> { + if self + .resolver() + .is_some_and(|resolver| resolver.view == ResolverView::Resolution) + { + self.resolver_diff_mut() + } else { + self.diff.as_mut() + } + } + + fn picker_diff_mut(&mut self) -> Option<&mut DiffState> { + if self.resolver().is_some() { + self.resolver_diff_mut() + } else { + self.diff.as_mut() + } + } + + pub(crate) fn picker_diff(&self) -> Option<&DiffState> { + self.resolver_workspace_diff().or(self.diff.as_ref()) } pub(crate) fn workspace_diff(&self) -> Option<&DiffState> { @@ -4095,6 +4093,11 @@ impl Editor { if let Some(diff) = &mut self.diff { diff.set_context_options(context_lines, expand_lines); } + for resolver in self.resolvers.values_mut() { + for file in &mut resolver.files { + file.diff.set_context_options(context_lines, expand_lines); + } + } } pub fn set_diff_display_options(&mut self, view_mode: ViewMode, split_axis: SplitAxis) { @@ -4376,10 +4379,7 @@ impl Editor { && self.hover.is_none() && self.palette.is_none() && self.completion.is_none() - && !self - .file_picker - .as_ref() - .is_some_and(|picker| picker.focused) + && !self.file_picker().is_some_and(|picker| picker.focused) && self.sequence_pending().is_none() && !self.g_pending && self.line_pending.is_none() @@ -4409,29 +4409,32 @@ impl Editor { } pub fn diff_editable(&self) -> bool { - self.diff - .as_ref() - .is_some_and(|diff| diff.after_editable() && diff.live_buffer == Some(self.active.id)) + self.diff().is_some_and(|diff| { + self.diff_buffer_focus() + .is_some_and(|focus| diff.side_editable(focus)) + && if let Some(resolver) = self.resolver() { + resolver.files[resolver.current_file].draft_buffer == self.active.id + } else { + diff.live_buffer == Some(self.active.id) + } + }) } pub fn diff_editing(&self) -> bool { - self.diff_editable() - && self.diff_interacting() - && self - .diff - .as_ref() - .is_some_and(|diff| diff.focus == Focus::After) + self.diff_editable() && self.diff_interacting() } pub fn diff_interacting(&self) -> bool { self.diff_buffer_focus().is_some_and(|focus| { - self.diff - .as_ref() + self.diff() .is_some_and(|diff| diff.focus == focus && diff.phase == Phase::View) }) } pub(crate) fn diff_buffer_focus(&self) -> Option { + if self.resolver().is_some() { + return self.resolver_buffer_focus(); + } let (comparison, file, focus, buffer) = self.diff_bound?; self.diff .as_ref() @@ -4444,6 +4447,18 @@ impl Editor { } pub(crate) fn diff_semantic_tokens(&self, focus: Focus) -> &[SemanticToken] { + if let Some(resolver) = self.resolver() { + let pane = if focus == Focus::Before { + Some(resolver.draft_pane) + } else { + resolver.alternative_pane + }; + return pane + .and_then(|pane| self.pane_buffer_id(pane)) + .and_then(|buffer| self.buffer(buffer)) + .and_then(|buffer| buffer.semantic_tokens.as_deref()) + .unwrap_or_default(); + } let Some(diff) = self.diff.as_ref() else { return &[]; }; @@ -4467,7 +4482,7 @@ impl Editor { pub(crate) fn mutation_blocked(&self) -> bool { self.active.resolver_readonly || self.active.diff_snapshot.is_some() - || (self.diff.is_some() && !self.diff_editing()) + || (self.diff().is_some() && !self.diff_editing()) || self.resolvers.values().any(|resolver| { resolver.files.iter().any(|file| { (!file.text || file.whole_file_choice.is_some()) @@ -4554,6 +4569,36 @@ impl Editor { if self.diff_prompt_mode.is_some() { return; } + if let Some(resolver) = self.resolver() { + if resolver.view == ResolverView::Resolution { + let focus = self.resolver_diff().map(|diff| diff.focus); + let pane = match focus { + Some(Focus::Before) => Some(resolver.draft_pane), + Some(Focus::After) => resolver.alternative_pane, + _ => None, + }; + if let Some(pane) = pane.filter(|pane| *pane != self.pane_id) { + let from = self.resolver_buffer_focus(); + let row = self.cursor().row; + let wanted = display_column(&self.active.lines[row], self.cursor().column); + let synchronized = from.zip(focus).and_then(|(from, to)| { + self.resolver_diff()?.corresponding_row(from, to, row) + }); + self.select_pane(pane); + if let Some(row) = synchronized { + let row = row.min(self.active.lines.len() - 1); + let column = column_at_display( + &self.active.lines[row], + wanted, + self.max_column(row), + ); + self.active.view.targets = vec![Target::caret(Cursor { row, column })]; + self.active.view.primary = 0; + } + } + } + return; + } let Some(diff) = &self.diff else { return }; if diff.phase != Phase::View || !diff.selected_file_loaded() { return; @@ -4745,6 +4790,7 @@ impl Editor { } pub(crate) fn refresh_live_diff(&mut self) { + self.refresh_resolver_comparison(); for diff in self .diff .iter_mut() @@ -4776,12 +4822,7 @@ impl Editor { let mut moved = false; for index in 0..self.active.view.targets.len() { let cursor = self.active.view.targets[index].active; - let Some(row) = self - .diff - .as_ref() - .unwrap() - .clamp_source_row(focus, cursor.row) - else { + let Some(row) = self.diff().unwrap().clamp_source_row(focus, cursor.row) else { continue; }; if row != cursor.row { @@ -4801,14 +4842,14 @@ impl Editor { pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { self.pane_close_picker_origin = None; - if self.diff.as_ref().is_some_and(|diff| diff.focus != focus) { + if self.diff().is_some_and(|diff| diff.focus != focus) { self.pending_lsp_navigation = None; } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| editor.scroll_diff(focus, down)); return; } - if let Some(diff) = &mut self.diff + if let Some(diff) = self.picker_diff_mut() && diff.phase == Phase::View { diff.focus = focus; @@ -4821,7 +4862,7 @@ impl Editor { } self.bind_diff_buffer(); self.refresh_live_diff(); - if let Some(diff) = &mut self.diff { + if let Some(diff) = self.picker_diff_mut() { diff.follow_caret = false; } } @@ -4834,7 +4875,7 @@ impl Editor { }); return; } - if let Some(diff) = &mut self.diff + if let Some(diff) = self.interaction_diff_mut() && diff.phase == Phase::View { diff.scroll_horizontal(focus, right, width); @@ -4849,6 +4890,17 @@ impl Editor { left_before: usize, left_after: usize, ) { + if self.resolver().is_some() { + if let Some(diff) = self.resolver_diff_mut() + && diff.comparison_id == Some(comparison_id) + && diff.file == file + { + diff.top = top; + diff.left_before = left_before; + diff.left_after = left_after; + } + return; + } if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) && diff.comparison_id == Some(comparison_id) && diff.file == file @@ -4867,17 +4919,16 @@ impl Editor { { return self.handle_input(input); } - let phase = self.diff.as_ref().expect("active diff").phase; + let phase = self.picker_diff().expect("active diff").phase; let files_focused = - phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files; + phase == Phase::View && self.picker_diff().unwrap().focus == Focus::Files; if phase != Phase::View && input == Input::Escape { return self.execute_action(Action::DiffClose.entry(), ""); } let text_focused = phase != Phase::View || (files_focused && self - .diff - .as_ref() + .picker_diff() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.searching || files.view == FileView::List)); if text_focused @@ -4885,7 +4936,7 @@ impl Editor { && matches!(input, Input::Char(ch) if !ch.is_control()) { if !self.handle_selection_binding(&input) { - self.diff.as_mut().unwrap().handle(input); + self.picker_diff_mut().unwrap().handle(input); } return None; } @@ -4929,7 +4980,7 @@ impl Editor { if matches!(input, Input::Char('[' | ']')) { let focus = self.diff_buffer_focus().unwrap(); let row = self.cursor().row; - self.diff.as_mut().unwrap().expand_source_excerpt( + self.interaction_diff_mut().unwrap().expand_source_excerpt( focus, input == Input::Char(']'), row, @@ -4957,8 +5008,7 @@ impl Editor { if files_focused && input == Input::Char('c') && self - .diff - .as_ref() + .picker_diff() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.view == FileView::Tree && !files.searching) && input != self.leader @@ -5008,15 +5058,18 @@ impl Editor { } let escape = input == Input::Escape; let return_focus = files_focused - .then(|| self.diff_bound.map(|bound| bound.2)) + .then(|| { + self.diff_buffer_focus() + .or(self.diff_bound.map(|bound| bound.2)) + }) .flatten(); - self.diff.as_mut().unwrap().handle(input); + self.picker_diff_mut().unwrap().handle(input); if files_focused && escape - && self.diff.as_ref().unwrap().focus != Focus::Files + && self.picker_diff().unwrap().focus != Focus::Files && let Some(focus) = return_focus { - let diff = self.diff.as_mut().unwrap(); + let diff = self.picker_diff_mut().unwrap(); diff.focus = focus; diff.normalize_focus(); } @@ -6230,7 +6283,7 @@ impl Editor { } pub fn file_picker(&self) -> Option<&FilePicker> { - if let Some(diff) = &self.diff { + if let Some(diff) = self.resolver_workspace_diff().or(self.diff.as_ref()) { diff.files.as_ref().filter(|_| diff.files_visible) } else { self.file_picker.as_ref() @@ -6311,6 +6364,13 @@ impl Editor { { picker.set_view(view); } + if let Some(picker) = self + .picker_diff_mut() + .and_then(|diff| diff.files.as_mut()) + .filter(|picker| picker.presentation == presentation) + { + picker.set_view(view); + } Ok(()) } @@ -6696,10 +6756,8 @@ impl Editor { } pub(crate) fn document_actions_available(&self) -> bool { - (self.diff.is_none() || self.diff_interacting()) - && !self - .resolver() - .is_some_and(|resolver| resolver.files_focused) + (self.diff().is_none() || self.diff_interacting()) + && !self.file_picker().is_some_and(|picker| picker.focused) } pub fn set_message(&mut self, message: impl Into) { @@ -6982,11 +7040,7 @@ impl Editor { } pub fn file_navigation_hint(&self) -> &str { - if let Some(picker) = self - .diff - .as_ref() - .and_then(|diff| diff.files.as_ref()) - .or(self.file_picker.as_ref()) + if let Some(picker) = self.file_picker() && picker.view == FileView::Tree { if picker.searching { @@ -7054,8 +7108,15 @@ impl Editor { } fn handle_tree_binding(&mut self, input: &Input) -> bool { - let picker = if let Some(diff) = &mut self.diff { - diff.files.as_mut().filter(|_| diff.files_visible) + let binding = match self.tree_bindings.get(input) { + Some(Binding::Action(action)) => Some(*action), + Some(Binding::Disabled) => None, + None => return false, + _ => unreachable!("tree bindings only accept navigation or disabled"), + }; + let picker = if self.picker_diff().is_some() { + self.picker_diff_mut() + .and_then(|diff| diff.files.as_mut().filter(|_| diff.files_visible)) } else { self.file_picker.as_mut() }; @@ -7066,13 +7127,12 @@ impl Editor { if picker.searching && matches!(input, Input::Char(_)) { return false; } - match self.tree_bindings.get(input) { - Some(Binding::Action(Action::Down)) => picker.navigate(true), - Some(Binding::Action(Action::Up)) => picker.navigate(false), - Some(Binding::Action(Action::Left)) => picker.left(), - Some(Binding::Action(Action::Right)) => picker.right(), - Some(Binding::Disabled) => {} - None => return false, + match binding { + Some(Action::Down) => picker.navigate(true), + Some(Action::Up) => picker.navigate(false), + Some(Action::Left) => picker.left(), + Some(Action::Right) => picker.right(), + None => {} _ => unreachable!("tree bindings only accept navigation or disabled"), } true @@ -7099,7 +7159,7 @@ impl Editor { palette.selected - 1 }; } else if self.palette.is_none() - && let Some(diff) = &mut self.diff + && let Some(diff) = self.picker_diff_mut() { diff.navigate(next); } @@ -7360,6 +7420,12 @@ impl Editor { fn open_file_picker(&mut self, presentation: FilePresentation) { self.pane_close_picker_origin = None; + if self + .resolver() + .is_some_and(|resolver| resolver.files.is_empty()) + { + return; + } if presentation == FilePresentation::Docked && self.file_picker().is_some_and(|picker| { picker.focused @@ -7370,7 +7436,7 @@ impl Editor { { let return_focus = self.diff_bound.map(|bound| bound.2); self.blur_file_picker(); - if let Some(diff) = &mut self.diff + if let Some(diff) = self.picker_diff_mut() && let Some(focus) = return_focus { diff.focus = focus; @@ -7382,7 +7448,7 @@ impl Editor { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, }; - if let Some(diff) = &mut self.diff { + if let Some(diff) = self.picker_diff_mut() { if diff.phase == Phase::View { let picker = diff.files.as_mut().expect("comparison picker"); picker.presentation = presentation; @@ -7433,7 +7499,7 @@ impl Editor { } fn blur_file_picker(&mut self) { - if let Some(diff) = &mut self.diff + if let Some(diff) = self.picker_diff_mut() && diff.phase == Phase::View { if let Some(picker) = &mut diff.files { @@ -7456,12 +7522,13 @@ impl Editor { fn close_file_picker(&mut self) { self.pane_close_picker_origin = None; - if let Some(diff) = &mut self.diff { + let return_focus = self.diff_bound.map(|bound| bound.2); + if let Some(diff) = self.picker_diff_mut() { diff.files_visible = false; - diff.focus = self - .diff_bound - .map(|bound| bound.2) - .unwrap_or_else(|| diff.first_text_focus()); + if let Some(picker) = &mut diff.files { + picker.focused = false; + } + diff.focus = return_focus.unwrap_or_else(|| diff.first_text_focus()); diff.normalize_focus(); } else { self.file_picker = None; @@ -7516,7 +7583,7 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; - if self.handle_resolver_input(&input) { + if self.handle_resolver_history_focus(&input) { return None; } if self.palette.is_none() @@ -7533,18 +7600,30 @@ impl Editor { self.pane_close_picker_origin = None; } self.bind_diff_buffer(); - let invocation = if self.diff.is_some() { + let invocation = if self.diff().is_some() + || (self.resolver().is_some() + && self.file_picker().is_some_and(|picker| picker.focused)) + { self.handle_diff(input) } else { self.handle_input(input) }; + if let Some(file) = self + .resolver_diff_mut() + .and_then(|diff| diff.requested_file.take()) + && self + .resolver() + .is_some_and(|resolver| resolver.current_file != file) + { + self.select_resolver_file(file); + } self.leave_diff_on_navigation(); self.bind_diff_buffer(); self.refresh_live_diff(); self.refresh_resolver_status(); self.refresh_resolver_comparison(); if self.diff_interacting() - && let Some(diff) = &mut self.diff + && let Some(diff) = self.interaction_diff_mut() { diff.follow_caret = true; } @@ -7553,7 +7632,7 @@ impl Editor { fn handle_input(&mut self, input: Input) -> Option { let mode = self.binding_mode(); - if self.completion.is_some() && (self.diff.is_none() || self.diff_editing()) { + if self.completion.is_some() && (self.diff().is_none() || self.diff_editing()) { let request = matches!( self.bindings .get(&self.active.view.mode) @@ -7945,7 +8024,7 @@ impl Editor { } fn open_action_prompt(&mut self, entry: &CoreAction) { - if self.diff.is_some() && !self.diff_interacting() { + if self.diff().is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); self.palette = None; self.count = None; @@ -8039,7 +8118,7 @@ impl Editor { ( self.tab_id, self.pane_id, - self.diff.as_ref().and_then(|diff| diff.comparison_id), + self.picker_diff().and_then(|diff| diff.comparison_id), ) }); self.blur_file_picker(); @@ -8208,7 +8287,7 @@ impl Editor { Action::Redo => self.redo(), Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { - if self.diff.is_some() && !self.diff_interacting() { + if self.diff().is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); } self.active.view.mode = if matches!(action, Action::Search) { @@ -9039,7 +9118,10 @@ impl Editor { self.diff_return_buffer = Some(self.active.id); return None; } - let Some(diff) = self.diff.as_mut().filter(|diff| diff.phase == Phase::View) else { + let Some(diff) = self + .picker_diff_mut() + .filter(|diff| diff.phase == Phase::View) + else { self.set_message("No comparison is open"); return None; }; @@ -9086,11 +9168,7 @@ impl Editor { self.set_message("The word_wrap.toggle command takes no arguments"); return None; } - if self.resolver_diff().is_some() { - let diff = self.resolver_diff_mut().unwrap(); - diff.word_wrap = !diff.word_wrap; - diff.follow_caret = true; - } else if let Some(diff) = &mut self.diff + if let Some(diff) = self.interaction_diff_mut() && diff.phase == Phase::View { diff.word_wrap = !diff.word_wrap; @@ -9112,6 +9190,10 @@ impl Editor { return self.with_pane(owner, |editor| editor.execute_action(entry, args)); } if args.is_empty() { + if self.resolver().is_some() { + self.close_tab(false); + return None; + } self.diff.as_ref()?; self.release_diff_buffers(); self.diff = None; @@ -9851,7 +9933,7 @@ impl Editor { // ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers. let diff = self .diff_buffer_focus() - .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); + .and_then(|focus| self.diff().map(|diff| (diff, focus))); let candidates: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { @@ -11116,7 +11198,7 @@ impl Editor { ) -> impl DoubleEndedIterator + '_ { let diff = self .diff_buffer_focus() - .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); + .and_then(|focus| self.diff().map(|diff| (diff, focus))); (start..if diff.is_none() { end } else { start }).chain( diff.into_iter() .flat_map(|(diff, focus)| diff.source_rows(focus)) @@ -11156,7 +11238,9 @@ impl Editor { && let Some(focus) = self.diff_buffer_focus() { let row = count.saturating_sub(1).min(self.active.lines.len() - 1); - self.diff.as_mut().unwrap().reveal_source_row(focus, row); + self.interaction_diff_mut() + .unwrap() + .reveal_source_row(focus, row); } for index in 0..self.active.view.targets.len() { let mut cursor = self.active.view.targets[index].active; @@ -12502,7 +12586,7 @@ mod tests { editor.handle(Input::Enter); assert_eq!(editor.active.view.search_query, "i"); editor.execute("resolve.files"); - assert!(editor.resolver().unwrap().files_focused); + assert!(editor.file_picker().unwrap().focused); editor.execute("resolve.close"); assert_eq!(editor.tab_id(), tab); editor.execute("pane.force_close"); @@ -12513,6 +12597,188 @@ mod tests { assert!(editor.dirty()); } + #[test] + fn resolver_loading_has_no_bound_diff_buffer() { + let mut editor = editor(); + let source = editor.buffer_id(); + editor.open_resolver(); + assert!(editor.resolver().unwrap().loading); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + assert!(editor.diff_buffer_focus().is_none()); + editor.execute("resolve.close"); + assert_eq!(editor.buffer_id(), source); + editor.refresh_live_diff(); + assert!(editor.diff_buffer_focus().is_none()); + } + + #[test] + fn resolver_rebuilding_panes_revokes_cached_diff_binding_until_ready() { + let mut editor = resolver_editor("draft", &[("draft", false), ("alternative", false)]); + let draft = editor.buffer_id(); + let alternative = editor + .resolvers + .get_mut(&editor.tab_id) + .unwrap() + .alternative_pane + .take(); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + assert_eq!(editor.buffer_id(), draft); + editor + .resolvers + .get_mut(&editor.tab_id) + .unwrap() + .alternative_pane = alternative; + editor.refresh_live_diff(); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); + editor.execute("resolve.history"); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + editor.execute("resolve.draft"); + assert_eq!(editor.buffer_id(), draft); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); + } + + #[test] + fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() { + let directory = Directory::new(); + let mut before: Vec<_> = (0..90).map(|row| format!("row {row}")).collect(); + before[20] = "needle hidden".into(); + before[70] = "needle visible".into(); + let mut after = before.clone(); + for row in [10, 70] { + after[row].push_str(" alternative"); + } + let draft = before.join("\n"); + let alternative = after.join("\n"); + let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); + let mut ordinary = editor(); + historical_diff(&mut ordinary, &directory.0, before, after); + for editor in [&mut resolver, &mut ordinary] { + editor.set_diff_context_options(1, 2); + editor + .bind("normal", "M", Some("core.down".into())) + .unwrap(); + type_text(editor, "11gMM"); + assert_eq!(editor.cursor().row, 69); + type_text(editor, "/needle"); + editor.handle(Input::Enter); + assert_eq!(editor.cursor().row, 70); + let rows = editor.diff().unwrap().source_rows(Focus::Before).count(); + let cursor = editor.cursor(); + editor.handle(Input::Char(']')); + assert!(editor.diff().unwrap().source_rows(Focus::Before).count() > rows); + assert_eq!(editor.cursor(), cursor); + type_text(editor, "21g"); + assert_eq!(editor.cursor().row, 20); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 20) + .is_some() + ); + } + assert_eq!( + resolver.diff().unwrap().display_rows, + ordinary.diff().unwrap().display_rows + ); + assert!(resolver.diff_editing()); + assert_eq!(resolver.diff().unwrap().side_label(Focus::Before), "Draft"); + assert_eq!( + resolver.diff().unwrap().side_label(Focus::After), + "Alternative" + ); + assert!(resolver.diff().unwrap().side_editable(Focus::Before)); + assert!(!resolver.diff().unwrap().side_editable(Focus::After)); + let draft_buffer = resolver.buffer_id(); + resolver.handle(Input::Char('\t')); + assert_eq!(resolver.diff_buffer_focus(), Some(Focus::After)); + assert!(!resolver.diff_editing()); + let text = resolver.context().text; + type_text(&mut resolver, "ix"); + assert_eq!(resolver.context().text, text); + resolver.handle(Input::BackTab); + assert_eq!(resolver.buffer_id(), draft_buffer); + assert!(resolver.diff_editing()); + } + + #[test] + fn resolver_shared_layout_remaps_and_wheel_preserve_native_draft_identity() { + let draft = (0..50) + .map(|row| format!("draft {row}\n")) + .collect::(); + let alternative = draft.replace("draft 20", "alternative 20"); + let mut editor = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); + let buffer = editor.buffer_id(); + let cursor = editor.cursor(); + for (key, action) in [ + ("Q", "core.diff.view.toggle"), + ("W", "core.diff.orientation.toggle"), + ("E", "core.word_wrap.toggle"), + ] { + editor.bind("normal", key, Some(action.into())).unwrap(); + } + editor.handle(Input::Char('W')); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + editor.handle(Input::Char('Q')); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + editor.handle(Input::Char('E')); + assert!(editor.word_wrap()); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("native ".into())); + let insert_cursor = editor.cursor(); + let diff = editor.diff().unwrap(); + assert!(diff.follow_caret); + editor.set_diff_viewport( + diff.comparison_id.unwrap(), + diff.file, + diff.top, + diff.left_before, + diff.left_after, + ); + assert!(editor.diff().unwrap().follow_caret); + editor.scroll_diff(Focus::Before, true); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), insert_cursor); + assert_eq!(editor.buffer_id(), buffer); + assert!(!editor.diff().unwrap().follow_caret); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, draft); + assert_eq!(editor.cursor(), cursor); + editor.set_diff_pane_bounds([ + (Focus::Files, PaneRect::default()), + ( + Focus::Before, + PaneRect { + x: 0, + y: 0, + width: 80, + height: 10, + }, + ), + ( + Focus::After, + PaneRect { + x: 0, + y: 11, + width: 80, + height: 10, + }, + ), + ]); + editor.execute("pane.focus_down"); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::After)); + editor.execute("pane.focus_up"); + assert_eq!(editor.buffer_id(), buffer); + assert!(editor.diff_editing()); + } + fn resolver_editor(draft: &str, sources: &[(&str, bool)]) -> Editor { let mut editor = editor(); editor.apply(vec![Effect::Replace(draft.into())]).unwrap(); @@ -12585,11 +12851,12 @@ mod tests { ], ); editor.active.view.targets = vec![Target::caret(Cursor { row: 20, column: 3 })]; - editor.set_resolver_viewport(8, 2, 4); + let diff = editor.diff().unwrap(); + editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4); let cursor = editor.cursor(); let buffer = editor.buffer_id(); let id = editor.resolver_diff().unwrap().comparison_id; - assert!(!editor.resolver().unwrap().files_visible); + assert!(editor.file_picker().is_none()); editor.execute("word_wrap.toggle"); assert!(editor.word_wrap()); editor.execute("resolve.history"); @@ -12617,11 +12884,11 @@ mod tests { assert_eq!(editor.resolver_buffer_focus(), Some(Focus::After)); editor.handle(Input::Char('\t')); assert_eq!(editor.resolver_buffer_focus(), Some(Focus::Before)); - assert!(!editor.resolver().unwrap().files_focused); + assert!(!editor.file_picker().is_some_and(|picker| picker.focused)); editor.execute("resolve.files"); - assert!(editor.resolver().unwrap().files_focused); + assert!(editor.file_picker().unwrap().focused); editor.execute("resolve.files"); - assert!(!editor.resolver().unwrap().files_visible); + assert!(editor.file_picker().is_none()); } #[test] @@ -12693,10 +12960,35 @@ mod tests { }) .collect(), }); - editor.select_resolver_file(1); + editor.select_resolver_file(0); + let rows = editor.diff().unwrap().display_rows.clone(); + editor + .bind("normal", "P", Some("core.file_picker".into())) + .unwrap(); + editor.handle(Input::Char('P')); + type_text(&mut editor, "second"); + editor.handle(Input::Enter); + assert_eq!(editor.resolver().unwrap().current_file, 1); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!( + editor + .diff() + .unwrap() + .comparison + .as_ref() + .unwrap() + .files + .len(), + 2 + ); assert_eq!(editor.context().text, "second\n"); assert_ne!(editor.resolver_diff().unwrap().comparison_id, comparison); - editor.select_resolver_file(0); + editor.handle(Input::Char('P')); + editor.handle(Input::Ctrl('u')); + type_text(&mut editor, "conflict"); + editor.handle(Input::Enter); + assert_eq!(editor.resolver().unwrap().current_file, 0); + assert_eq!(editor.diff().unwrap().display_rows, rows); assert_eq!(editor.context().text, "first\n"); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.resolver_diff().unwrap().comparison_id, comparison); diff --git a/src/session.rs b/src/session.rs index f174872..99c347a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -611,25 +611,6 @@ impl EditorSession { self.synchronize_documents(); } - pub(crate) fn set_resolver_viewport( - &mut self, - top: usize, - left_before: usize, - left_after: usize, - ) { - self.editor - .set_resolver_viewport(top, left_before, left_after); - } - - pub(crate) fn scroll_resolver(&mut self, focus: Focus, down: bool) { - self.editor.scroll_resolver_diff(focus, down); - } - - pub(crate) fn scroll_resolver_horizontal(&mut self, focus: Focus, right: bool, width: usize) { - self.editor - .scroll_resolver_diff_horizontal(focus, right, width); - } - pub fn handle(&mut self, input: Input) { if input == Input::Ctrl('r') && self diff --git a/src/tui.rs b/src/tui.rs index 6bedf09..bec6f7b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -654,15 +654,17 @@ impl BufferViewports { self.syntax.retain(|id, _| editor.has_buffer(*id)); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); let full_workspace = draw_bars(frame, editor, frame.area(), syntax); - if editor.resolver().is_some() { - self.draw_resolver(frame, editor, full_workspace); - return; - } - let multiple = editor.pane_layout().len() > 1; + let resolver_resolution = editor + .resolver() + .is_some_and(|resolver| resolver.view == ResolverView::Resolution); + let resolver_history = editor + .resolver() + .is_some_and(|resolver| resolver.view == ResolverView::History); + let multiple = !resolver_resolution && editor.pane_layout().len() > 1; let (workspace, dock) = match editor.file_picker() { Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout( full_workspace, - if editor.diff().is_some() { + if editor.picker_diff().is_some() { DockSide::Right } else { editor.file_explorer_side() @@ -673,16 +675,24 @@ impl BufferViewports { }; editor.set_pane_bounds(pane_rect(workspace)); self.rects.clear(); - editor - .pane_layout() - .visit_rects(pane_rect(workspace), |id, area| { - self.rects.push((id, terminal_rect(area))); - }); + if resolver_history { + let layout = resolver_history_layout(workspace, editor.resolver().unwrap(), focused); + draw_resolver_heading(frame, editor.resolver().unwrap(), layout.heading); + self.draw_resolver_history(frame, editor, &layout); + } else if resolver_resolution { + self.rects.push((viewport_pane, workspace)); + } else { + editor + .pane_layout() + .visit_rects(pane_rect(workspace), |id, area| { + self.rects.push((id, terminal_rect(area))); + }); + } // Paint focused overlays last, so an adjacent leaf cannot erase them. - for active_pass in [false, true] { + for active_pass in [false, true].into_iter().filter(|_| !resolver_history) { for index in 0..self.rects.len() { let (id, mut leaf) = self.rects[index]; - if (id == focused) != active_pass { + if ((id == focused) || resolver_resolution) != active_pass { continue; } self.activate(id, editor); @@ -700,7 +710,8 @@ impl BufferViewports { frame.render_widget(block, leaf); leaf = inner; } - editor.with_render_pane(id, |view| { + let render_pane = if resolver_resolution { focused } else { id }; + editor.with_render_pane(render_pane, |view| { self.activate_document(view.buffer_id()); self.documents.retain(|id, _| view.has_buffer(*id)); self.offsets.retain(|id, _| view.has_buffer(*id)); @@ -730,7 +741,7 @@ impl BufferViewports { } } } - self.activate(focused, editor); + self.activate(viewport_pane, editor); self.panels.workspace_managed = false; if self.rects.is_empty() { self.panels.begin_frame(); @@ -746,7 +757,7 @@ impl BufferViewports { panel, editor.file_navigation_hint(), &mut self.panels, - editor.diff().and_then(|diff| { + editor.picker_diff().and_then(|diff| { diff.comparison .as_ref() .map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)) @@ -761,6 +772,41 @@ impl BufferViewports { ); } } + if resolver_history { + editor.set_resolver_history_bounds( + self.rects.iter().map(|(id, area)| (*id, pane_rect(*area))), + dock.map(pane_rect), + ); + if let Some(diff) = editor.picker_diff() { + self.panels.diff = Some(DiffGeometry { + regions: [ + (dock.unwrap_or_default(), Focus::Files), + (Rect::default(), Focus::Before), + (Rect::default(), Focus::After), + ], + viewport: None, + text_widths: [0; 2], + wrapping: diff.word_wrap, + view_mode: diff.view_mode, + split_axis: diff.split_axis, + caret_row: None, + }); + } + } + if resolver_resolution { + self.rects.clear(); + if let Some(geometry) = &self.panels.diff { + let resolver = editor.resolver().unwrap(); + for (pane, region) in [ + (Some(resolver.draft_pane), geometry.regions[1].0), + (resolver.alternative_pane, geometry.regions[2].0), + ] { + if let Some(pane) = pane.filter(|_| !region.is_empty()) { + self.rects.push((pane, region)); + } + } + } + } if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) { draw_diff( frame, @@ -793,79 +839,11 @@ impl BufferViewports { .any(|geometry| geometry.viewport.is_some()); } - fn draw_resolver(&mut self, frame: &mut Frame, editor: &mut Editor, area: Rect) { - let focused = editor.pane_id(); - let resolver = editor.resolver().expect("resolver workspace is active"); - let layout = resolver_layout(area, resolver, focused); - frame.render_widget(Clear, area); - draw_resolver_heading(frame, resolver, layout.heading); - self.rects.clear(); - let resolution = resolver.view == ResolverView::Resolution; - let text = resolver - .files - .get(resolver.current_file) - .is_some_and(|file| file.text); - if resolution && text { - self.panels.begin_frame(); - self.panels.workspace_managed = true; - self.document.geometry = None; - if let Some(diff) = editor.resolver_diff() { - draw_diff( - frame, - diff, - layout.documents, - editor.selection_navigation_hint(), - editor.file_navigation_hint(), - &mut self.panels, - Some(editor), - ); - } - if let Some(geometry) = &self.panels.diff { - let resolver = editor.resolver().unwrap(); - for (pane, region) in [ - (Some(resolver.draft_pane), geometry.regions[1].0), - (resolver.alternative_pane, geometry.regions[2].0), - ] { - if let Some(pane) = pane.filter(|_| !region.is_empty()) { - self.rects.push((pane, region)); - } - } - } - } else if resolution { - self.panels.begin_frame(); - draw_resolver_choices(frame, resolver, layout.documents); - } else { - self.draw_resolver_history(frame, editor, &layout); - } - editor.set_pane_bounds(pane_rect(area)); - if !resolution { - self.activate(focused, editor); - } - self.panels.workspace_managed = false; - if !resolution && self.rects.is_empty() { - self.panels.begin_frame(); - self.document.geometry = None; - } - let resolver = editor.resolver().expect("resolver workspace is active"); - self.panels.resolver_files = layout.files; - if let Some(files) = layout.files { - draw_resolver_files( - frame, - resolver, - files, - editor.file_navigation_hint(), - &mut self.panels, - ); - } - draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels); - self.diff_visible = text; - } - fn draw_resolver_history( &mut self, frame: &mut Frame, editor: &mut Editor, - layout: &ResolverLayout, + layout: &ResolverHistoryLayout, ) { self.panels.begin_frame(); self.document.geometry = None; @@ -895,7 +873,8 @@ impl BufferViewports { frame, panel, &format!("{} · readonly", source.label), - !resolver.files_focused && pane == editor.pane_id(), + !editor.file_picker().is_some_and(|picker| picker.focused) + && pane == editor.pane_id(), ); panes[slot] = Some((column, pane, content)); self.rects.push((pane, content)); @@ -940,7 +919,7 @@ impl BufferViewports { editor.caret_reveal_revision(), history.wrapping[column], ); - if !editor.resolver().unwrap().files_focused + if !editor.file_picker().is_some_and(|picker| picker.focused) && (history.caret != Some(identity) || previous_widths != history.widths) { let diff = &history.columns[column]; @@ -1031,7 +1010,9 @@ impl BufferViewports { view.resolver(), true, ); - if view.render_pane_focused() && !view.resolver().unwrap().files_focused { + if view.render_pane_focused() + && !view.file_picker().is_some_and(|picker| picker.focused) + { self.panels.caret = paint_diff_editor( frame, view, @@ -1055,11 +1036,7 @@ impl BufferViewports { .as_ref() .and_then(|geometry| geometry.viewport) { - if session.editor().resolver_diff().is_some() { - session.set_resolver_viewport(top, left_before, left_after); - } else { - session.set_diff_viewport(comparison_id, file, top, left_before, left_after); - } + session.set_diff_viewport(comparison_id, file, top, left_before, left_after); } if session.editor().resolver().is_some() { return; @@ -2007,38 +1984,26 @@ fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut Syntax workspace } -struct ResolverLayout { +struct ResolverHistoryLayout { heading: Rect, - documents: Rect, history: Vec<(usize, Rect)>, - files: Option, } -fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> ResolverLayout { - let [heading, workspace] = Layout::vertical([ +fn resolver_history_layout( + area: Rect, + resolver: &ResolverWorkspace, + focused: usize, +) -> ResolverHistoryLayout { + let [heading, documents] = Layout::vertical([ Constraint::Length(u16::from(area.height >= 4)), Constraint::Fill(1), ]) .areas(area); - let (documents, files) = if resolver.files_visible && area.width >= 76 { - let [documents, files] = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Length((area.width / 4).clamp(24, 36)), - ]) - .areas(workspace); - (documents, Some(files)) - } else if resolver.files_focused { - (Rect::default(), Some(workspace)) - } else { - (workspace, None) - }; - let mut layout = ResolverLayout { + let mut layout = ResolverHistoryLayout { heading, - documents, history: Vec::with_capacity(3), - files, }; - if resolver.view != ResolverView::History || documents.is_empty() { + if documents.is_empty() { return layout; } let Some(file) = resolver.files.get(resolver.current_file) else { @@ -2147,9 +2112,12 @@ fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: let Some(file) = resolver.files.get(resolver.current_file) else { return; }; + let (status, color) = resolver_status(&file.status); let mut lines = vec![ - Line::from("Whole-file conflict · text editing is unavailable") - .style(Style::default().fg(Color::Yellow)), + Line::from(format!( + "Whole-file conflict · {status} · text editing is unavailable" + )) + .style(Style::default().fg(color)), Line::from( file.whole_file_choice .and_then(|index| file.sources.get(index)) @@ -2181,142 +2149,6 @@ fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); } -fn draw_resolver_files( - frame: &mut Frame, - resolver: &ResolverWorkspace, - panel: Rect, - navigation_hint: &str, - panels: &mut WheelPanels, -) { - if panel.is_empty() { - return; - } - let bordered = panel.width >= 4 && panel.height >= 4; - let block = Block::default() - .borders(if bordered { - Borders::ALL - } else { - Borders::NONE - }) - .border_style(Style::default().fg(if resolver.files_focused { - Color::Cyan - } else { - Color::DarkGray - })) - .title(" Conflicts "); - let content = block.inner(panel); - frame.render_widget(Clear, panel); - frame.render_widget(block, panel); - let [query, entries, help] = Layout::vertical([ - Constraint::Length(u16::from(content.height >= 2)), - Constraint::Fill(1), - Constraint::Length(u16::from(content.height >= 5)), - ]) - .areas(content); - let prompt = if resolver.filtering { - format!("> {}", display_text(&resolver.filter)) - } else if resolver.filter.is_empty() { - "/ filter".to_owned() - } else { - format!("filter: {}", display_text(&resolver.filter)) - }; - let prompt_width = display_column(&prompt, usize::MAX); - let prompt_offset = if resolver.filtering { - prompt_width.saturating_sub(usize::from(query.width.saturating_sub(1))) - } else { - 0 - }; - frame.render_widget( - Paragraph::new(visible_line( - &prompt, - prompt_offset, - usize::from(query.width), - )) - .style(Style::default().fg(Color::Yellow)), - query, - ); - let filter = resolver.filter.to_lowercase(); - let files: Vec<_> = resolver - .files - .iter() - .enumerate() - .filter(|(_, file)| file.path.to_string_lossy().to_lowercase().contains(&filter)) - .collect(); - let selected = files - .iter() - .position(|(index, _)| *index == resolver.selected_file) - .unwrap_or(0); - let top = PickerViewport::follow( - &mut panels.resolver_scroll, - 0, - selected, - usize::from(entries.height), - 1, - Some(0), - ); - let lines: Vec<_> = files - .iter() - .skip(top) - .take(usize::from(entries.height)) - .map(|(index, file)| { - let (status, color) = resolver_status(&file.status); - let status = match file.status { - ResolverStatus::Unsaved => "unsaved", - ResolverStatus::Checking => "checking", - ResolverStatus::Unconfirmed => "unconfirmed", - _ => status, - }; - Line::from(vec![ - Span::styled(format!("{status} "), Style::default().fg(color)), - Span::raw(display_path(&file.path)), - ]) - .style(if *index == resolver.selected_file { - Style::default() - .bg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }) - }) - .collect(); - frame.render_widget( - Paragraph::new(if files.is_empty() { - vec![Line::from("No matching files")] - } else { - lines - }), - entries, - ); - frame.render_widget( - Paragraph::new(format!("{navigation_hint} · Enter open · Tab panes")) - .style(Style::default().fg(Color::DarkGray)), - help, - ); - if resolver.files_focused { - let position = if resolver.filtering && !query.is_empty() { - Some(Position::new( - query.x - + (prompt_width - prompt_offset).min(usize::from(query.width.saturating_sub(1))) - as u16, - query.y, - )) - } else if !entries.is_empty() - && selected >= top - && selected - top < usize::from(entries.height) - { - Some(Position::new( - entries.x, - entries.y + (selected - top) as u16, - )) - } else { - None - }; - if let Some(position) = position { - frame.set_cursor_position(position); - } - } -} - #[cfg(test)] fn draw( frame: &mut Frame, @@ -2996,7 +2828,7 @@ fn draw_overlays( panel, editor.file_navigation_hint(), panels, - editor.diff().and_then(|diff| { + editor.picker_diff().and_then(|diff| { diff.comparison .as_ref() .map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)) @@ -3345,19 +3177,6 @@ fn paint_editor_line( } } -fn diff_native_focus(editor: &Editor) -> Option { - editor - .resolver_buffer_focus() - .or_else(|| editor.diff_buffer_focus()) -} - -fn diff_native_interacting(editor: &Editor) -> bool { - editor.resolver().map_or_else( - || editor.diff_interacting(), - |resolver| !resolver.files_focused && editor.resolver_buffer_focus().is_some(), - ) -} - fn paint_diff_editor( frame: &mut Frame, editor: &Editor, @@ -3393,7 +3212,7 @@ fn paint_diff_editor( paint_editor_line(frame, editor, source, line_area, left, &carets, &selections); if source == source_cursor.row && editor.render_pane_focused() - && (diff_native_interacting(editor) + && (editor.diff_interacting() || editor .resolver() .is_some_and(|resolver| resolver.view == ResolverView::History)) @@ -3718,12 +3537,23 @@ fn draw_diff( } frame.render_widget(Clear, area); + let resolver = editor + .and_then(Editor::resolver) + .filter(|resolver| resolver.view == ResolverView::Resolution); + let resolver_error = resolver.and_then(|resolver| { + resolver.error.as_deref().or_else(|| { + resolver + .files + .get(resolver.current_file) + .and_then(|file| file.error.as_deref()) + }) + }); let syntax_error = panels .diff_syntax .as_ref() .and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error())); - let notice = diff.error.as_deref().or_else(|| { - if diff.loading { + let notice = diff.error.as_deref().or(resolver_error).or_else(|| { + if diff.loading || resolver.is_some_and(|resolver| resolver.loading) { Some("Loading file…") } else { syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref())) @@ -3737,7 +3567,7 @@ fn draw_diff( if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( - if diff.error.is_some() || syntax_error.is_some() { + if diff.error.is_some() || resolver_error.is_some() || syntax_error.is_some() { Color::Red } else { Color::Yellow @@ -3757,23 +3587,47 @@ fn draw_diff( } else { (workspace, None) }; - let resolver = editor - .and_then(Editor::resolver) - .filter(|resolver| resolver.view == ResolverView::Resolution); - let pane_layout = if resolver.is_some() { - PaneLayout::Split - } else { - diff.pane_layout() - }; + if let Some(picker) = &diff.files + && let Some(navigator) = navigator + { + draw_file_list( + frame, + picker, + navigator, + file_navigation_hint, + panels, + comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)), + ); + } + if let Some(resolver) = resolver.filter(|resolver| { + resolver + .files + .get(resolver.current_file) + .is_some_and(|file| !file.text) + }) { + draw_resolver_choices(frame, resolver, panes); + panels.diff = Some(DiffGeometry { + regions: [ + (navigator.unwrap_or_default(), Focus::Files), + (Rect::default(), Focus::Before), + (Rect::default(), Focus::After), + ], + viewport: None, + text_widths: [0; 2], + wrapping: diff.word_wrap, + view_mode: diff.view_mode, + split_axis: diff.split_axis, + caret_row: None, + }); + if let Some(editor) = editor { + draw_diff_popups(frame, editor, area, None, panels); + } + return; + } + let pane_layout = diff.pane_layout(); let unified = diff.view_mode == ViewMode::Unified; let [before_area, after_area] = if unified { [panes, panes] - } else if resolver.is_some() && panes.width < 20 { - if diff.focus == Focus::After { - [Rect::default(), panes] - } else { - [panes, Rect::default()] - } } else { match pane_layout { PaneLayout::Split => split_rect(pane_rect(panes), diff.split_axis).map(terminal_rect), @@ -3799,7 +3653,7 @@ fn draw_diff( }) .and_then(|previous| { let editor = editor?; - let focus = diff_native_focus(editor)?; + let focus = editor.diff_buffer_focus()?; if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis { return previous.caret_row; } @@ -3820,7 +3674,7 @@ fn draw_diff( } }); let center_row = editor.and_then(|editor| { - let focus = diff_native_focus(editor)?; + let focus = editor.diff_buffer_focus()?; let pane = if focus == Focus::Before { before_area } else { @@ -3828,7 +3682,7 @@ fn draw_diff( }; let height = usize::from(diff_inner(pane).height); let file = selected.filter(|file| file.loaded)?; - (diff_native_interacting(editor) + (editor.diff_interacting() && diff.follow_caret && height > 0 && diff_pane_text_width(diff, file, pane, focus) > 0 @@ -3841,8 +3695,12 @@ fn draw_diff( let toggle_row = center_row.or(toggle_row); if wrapping && let Some(file) = selected.filter(|file| file.loaded) { let caret = editor - .filter(|editor| diff_native_interacting(editor) && diff.follow_caret) - .and_then(|editor| diff_native_focus(editor).map(|focus| (focus, editor.cursor()))); + .filter(|editor| editor.diff_interacting() && diff.follow_caret) + .and_then(|editor| { + editor + .diff_buffer_focus() + .map(|focus| (focus, editor.cursor())) + }); let viewport = panels .diff_wrap .get_or_insert_with(DiffWrapViewport::default); @@ -3859,8 +3717,8 @@ fn draw_diff( panels.diff_wrap = None; } if let Some(editor) = - editor.filter(|editor| !wrapping && diff_native_interacting(editor) && diff.follow_caret) - && let Some(focus) = diff_native_focus(editor) + editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret) + && let Some(focus) = editor.diff_buffer_focus() { let (pane, left) = match focus { Focus::Before => (before_area, &mut before_left), @@ -3912,18 +3770,6 @@ fn draw_diff( }), ); } - if let Some(picker) = &diff.files - && let Some(navigator) = navigator - { - draw_file_list( - frame, - picker, - navigator, - file_navigation_hint, - panels, - comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)), - ); - } let mut text_widths = [0; 2]; let previous_text_widths = panels .previous_diff @@ -3971,7 +3817,7 @@ fn draw_diff( ); text_widths[0] = text_area.width; if let Some(editor) = - editor.filter(|editor| diff_native_focus(editor) == Some(Focus::Before)) + editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before)) { document_caret = paint_diff_editor( frame, @@ -4012,7 +3858,7 @@ fn draw_diff( ); text_widths[1] = text_area.width; if let Some(editor) = - editor.filter(|editor| diff_native_focus(editor) == Some(Focus::After)) + editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After)) { document_caret = paint_diff_editor( frame, @@ -4095,48 +3941,15 @@ fn diff_pane_panel( if area.width < 4 || area.height < 4 { return area; } - if let Some(resolver) = resolver { - let label = if before { " Draft " } else { " Alternative " }; - let state = if before { " editable " } else { " readonly " }; - let source = resolver - .files - .get(resolver.current_file) - .and_then(|file| { - file.sources.get(if before { - file.seed_source - } else { - resolver.selected_source - }) - }) - .map_or("", |source| source.label.as_str()); - let revision = if before { - format!(" seeded from {} ", display_text(source)) - } else { - format!(" {} ", display_text(source)) - }; - let focus = if before { Focus::Before } else { Focus::After }; - return diff_panel( - frame, - area, - label, - state, - &revision, - !resolver.files_focused && diff.focus == focus, - ); - } + let focus = if before { Focus::Before } else { Focus::After }; + let label = diff.side_label(focus); let title = file.map_or_else( || format!(" Diff · {} ", diff.comparison_mode.label()), |file| { let label = if diff.view_mode == ViewMode::Unified { - if before { - "Unified · Before" - } else { - "Unified · After" - } - } else if before { - "Before" + Cow::Owned(format!("Unified · {label}")) } else { - "After" + Cow::Borrowed(label) }; let path = if before { file.old_path.as_ref().unwrap_or(&file.path) @@ -4151,34 +3964,60 @@ fn diff_pane_panel( ) }, ); - let revision = diff - .comparison - .as_ref() - .map_or_else(String::new, |comparison| { - if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split { - format!( - " {} ", - display_text(if before { - &comparison.before - } else { - &comparison.after - }) - ) - } else { - format!( - " {} → {} ", - display_text(&comparison.before), - display_text(&comparison.after) - ) - } - }); - let state = if !before && diff.after_editable() { + let revision = if let Some(resolver) = resolver { + let source = resolver + .files + .get(resolver.current_file) + .and_then(|file| { + file.sources.get(if before { + file.seed_source + } else { + resolver.selected_source + }) + }) + .map_or("", |source| source.label.as_str()); + if before { + format!(" seeded from {} ", display_text(source)) + } else { + format!(" {} ", display_text(source)) + } + } else { + diff.comparison + .as_ref() + .map_or_else(String::new, |comparison| { + if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split { + format!( + " {} ", + display_text(if before { + &comparison.before + } else { + &comparison.after + }) + ) + } else { + format!( + " {} → {} ", + display_text(&comparison.before), + display_text(&comparison.after) + ) + } + }) + }; + let access = if diff.side_editable(focus) { " editable " } else { " read-only " }; - let focus = if before { Focus::Before } else { Focus::After }; - diff_panel(frame, area, &title, state, &revision, diff.focus == focus) + let state = if let Some(resolver) = resolver { + let status = resolver + .files + .get(resolver.current_file) + .map_or("loading", |file| resolver_status(&file.status).0); + Cow::Owned(format!("{access}· {status} ")) + } else { + Cow::Borrowed(access) + }; + diff_panel(frame, area, &title, &state, &revision, diff.focus == focus) } #[allow(clippy::too_many_arguments)] @@ -4926,8 +4765,6 @@ struct WheelPanels { diff_syntax: Option, previous_diff: Option, diff_wrap: Option, - resolver_files: Option, - resolver_scroll: Option, } struct PickerScroll { @@ -5038,7 +4875,6 @@ impl WheelPanels { self.completion = None; self.files = None; self.previous_diff = self.diff.take(); - self.resolver_files = None; } fn follow_files(&mut self) { @@ -5137,18 +4973,6 @@ impl WheelPanels { } return true; } - if self - .resolver_files - .is_some_and(|area| area.contains(position)) - { - if !horizontal { - session.editor_mut().focus_resolver_files(); - for _ in 0..3 { - session.handle(if down { Input::Down } else { Input::Up }); - } - } - return true; - } let pane = session.editor().pane_id(); let floating_files = editor.file_picker().is_some_and(|picker| { picker.presentation == FilePresentation::Floating @@ -5216,34 +5040,6 @@ impl WheelPanels { }) else { return false; }; - if let Some(diff) = session.editor().resolver_diff() { - let wrapping = diff.word_wrap; - let viewport = if !horizontal && wrapping { - diff.comparison.as_ref().and_then(|comparison| { - let file = comparison.files.get(diff.file)?; - let wrap = self.diff_wrap.as_mut()?; - for _ in 0..3 { - wrap.top = wrap.step(diff, file, wrap.top, down); - } - Some((wrap.top.display, diff.left_before, diff.left_after)) - }) - } else { - None - }; - if horizontal && !wrapping { - let width = - self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; - session.scroll_resolver_horizontal(focus, down, usize::from(width)); - } else if let Some((top, before, after)) = viewport { - session.scroll_resolver(focus, down); - session.set_resolver_viewport(top, before, after); - } else if !horizontal { - for _ in 0..3 { - session.scroll_resolver(focus, down); - } - } - return true; - } let (wrapping, wrapped_viewport) = session.editor_mut().with_render_pane(pane, |editor| { let wrapping = editor.word_wrap(); let viewport = if !horizontal && wrapping && focus != Focus::Files { @@ -7172,6 +6968,14 @@ mod tests { .unwrap(); terminal.get_cursor_position().unwrap() }; + let workspace = |terminal: &Terminal, + views: &BufferViewports, + editor: &Editor| { + let syntax = &views.syntax[&editor.buffer_id()]; + let visible = [editor.top_bar_config(), editor.bottom_bar_config()] + .map(|line| chrome_visible(editor, &chrome_groups(editor, line, syntax))); + document_layout(terminal.backend().buffer().area, visible)[1] + }; let caret = render(&mut terminal, &mut views, &mut session); let resolver = session.editor().resolver().unwrap(); assert_eq!(resolver.files.len(), 3); @@ -7188,7 +6992,7 @@ mod tests { assert_eq!((draft.y, draft.height), (alternative.y, alternative.height)); assert_eq!(draft.right(), alternative.x); assert!(draft.height > 20); - assert!(views.panels.resolver_files.is_none()); + assert!(views.panels.files.is_none()); let screen = screen_text(&terminal); assert!(screen.contains("Draft") && screen.contains("Alternative")); assert!(screen.contains("unchanged lines hidden")); @@ -7223,6 +7027,145 @@ mod tests { "Preexisting unsaved working-buffer edits must appear in both projections" ); } + let diff = session.editor().diff().unwrap(); + let file = &diff.comparison.as_ref().unwrap().files[diff.file]; + let mut reference_file = syntax_diff_file( + &file.path.to_string_lossy(), + &file.before.join("\n"), + &file.after.join("\n"), + ); + reference_file.before_eof = file.before_eof; + reference_file.after_eof = file.after_eof; + reference_file.hunks = crate::diff::line_hunks( + &reference_file.before, + reference_file.before_eof, + &reference_file.after, + reference_file.after_eof, + ); + let mut reference = Editor::open(None, vec![]).unwrap(); + for _ in 1..session.editor().tab_count() { + reference.execute("tab.new"); + } + reference + .set_chrome_config( + session.editor().top_bar_config().clone(), + session.editor().bottom_bar_config().clone(), + BTreeMap::new(), + ) + .unwrap(); + install_readonly_diff(&mut reference, reference_file); + reference.handle(Input::Ctrl('w')); + let cursor = session.editor().cursor(); + for ch in format!("{}g", cursor.row + 1).chars() { + reference.handle(Input::Char(ch)); + } + reference.handle(Input::Home); + for _ in 0..cursor.column { + reference.handle(Input::Right); + } + assert_eq!(reference.cursor(), cursor); + assert_eq!( + reference.diff_buffer_focus(), + session.editor().diff_buffer_focus() + ); + let mut reference_terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut reference_views = BufferViewports::default(); + reference_views.set_theme(Some(DARK_DIFF_THEME)); + for command in [ + None, + Some("diff.orientation.toggle"), + Some("diff.view.toggle"), + Some("diff.view.toggle"), + Some("diff.orientation.toggle"), + ] { + if let Some(command) = command { + session.execute(command); + reference.execute(command); + } + render(&mut terminal, &mut views, &mut session); + render_native_diff( + &mut reference_terminal, + &mut reference_views, + &mut reference, + ); + let actual = views.panels.diff.as_ref().unwrap(); + let expected = reference_views.panels.diff.as_ref().unwrap(); + assert_eq!(actual.view_mode, expected.view_mode); + assert_eq!(actual.split_axis, expected.split_axis); + assert_eq!(actual.text_widths, expected.text_widths); + for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..] + .iter() + .zip(&expected.regions[1..]) + .enumerate() + { + assert_eq!( + ( + actual_pane.x, + actual_pane.y, + actual_pane.width, + actual_pane.height + ), + ( + expected_pane.x, + expected_pane.y, + expected_pane.width, + expected_pane.height + ) + ); + let actual_text = Rect { + x: diff_inner(*actual_pane).right() - actual.text_widths[side], + width: actual.text_widths[side], + ..diff_inner(*actual_pane) + }; + let expected_text = Rect { + x: diff_inner(*expected_pane).right() - expected.text_widths[side], + width: expected.text_widths[side], + ..diff_inner(*expected_pane) + }; + for row in 0..actual_text.height { + assert_eq!( + line_at(&terminal, actual_text, actual_text.y + row), + line_at(&reference_terminal, expected_text, expected_text.y + row,), + "{command:?}, row {row}, full rows: {:?} / {:?}", + line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row), + line_at( + &reference_terminal, + diff_inner(*expected_pane), + expected_text.y + row, + ), + ); + } + } + } + let folded_context_rows = screen_text(&terminal).matches("context").count(); + session.handle(Input::Char(']')); + reference.handle(Input::Char(']')); + render(&mut terminal, &mut views, &mut session); + render_native_diff( + &mut reference_terminal, + &mut reference_views, + &mut reference, + ); + let folds = |editor: &Editor| { + editor + .diff() + .unwrap() + .display_rows + .iter() + .filter_map(|row| match row { + DisplayRow::Fold { start, end } => Some((*start, *end)), + _ => None, + }) + .collect::>() + }; + assert_eq!(folds(session.editor()), folds(&reference)); + let expanded_context_rows = screen_text(&terminal).matches("context").count(); + assert!(expanded_context_rows > folded_context_rows); + assert_eq!( + expanded_context_rows, + screen_text(&reference_terminal).matches("context").count() + ); let third_source = session.editor().resolver().unwrap().files[current_file] .sources .iter() @@ -7322,7 +7265,8 @@ mod tests { )); render(&mut terminal, &mut views, &mut session); assert!(views.panels.diff.as_ref().unwrap().viewport.unwrap().2 > before_scroll); - assert_eq!(session.editor().pane_id(), draft_pane); + assert_eq!(session.editor().pane_id(), alternative_pane); + assert_eq!(session.editor().diff_buffer_focus(), Some(Focus::After)); assert_eq!(session.editor().cursor(), cursor); let geometry = views.panels.diff.as_ref().unwrap(); assert!( @@ -7337,6 +7281,16 @@ mod tests { .contains("context") ); + session.execute("resolve.draft"); + let search_row = session + .editor() + .lines() + .iter() + .position(|line| line == "context 30") + .unwrap(); + for ch in format!("{}g", search_row + 1).chars() { + session.handle(Input::Char(ch)); + } session.handle(Input::Char('/')); for ch in "context 30".chars() { session.handle(Input::Char(ch)); @@ -7346,7 +7300,13 @@ mod tests { assert!(screen_text(&terminal).contains("context 30")); assert_eq!( terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, - Color::Yellow + Color::Yellow, + "source {:?} ({:?}), terminal {searched_caret:?}, caret {:?}, viewport {:?}, focus {:?}", + session.editor().cursor(), + session.editor().lines()[session.editor().cursor().row], + views.panels.caret, + views.panels.diff.as_ref().unwrap().viewport, + session.editor().diff_buffer_focus(), ); session.handle(Input::Char('v')); session.handle(Input::Right); @@ -7363,6 +7323,30 @@ mod tests { session.execute("resolve.history"); render(&mut terminal, &mut views, &mut session); assert_eq!(views.rects.len(), 3); + for (key, title) in [ + ('e', "Explorer · Tree NORMAL"), + ('o', "Files · Tree INSERT"), + ] { + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + let picker_caret = render(&mut terminal, &mut views, &mut session); + let panel = views.panels.files.unwrap().0; + assert!(panel.contains(picker_caret)); + assert!(screen_text(&terminal).contains(title)); + if key == 'e' { + assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, panel); + assert!(views.rects.iter().all(|(_, area)| area.right() <= panel.x)); + } else { + assert_eq!( + panel, + floating_panel(workspace(&terminal, &views, session.editor())) + ); + } + session.handle(Input::Ctrl('w')); + render(&mut terminal, &mut views, &mut session); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects.len(), 3); + } assert!( views .rects @@ -7497,19 +7481,97 @@ mod tests { assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport); assert_eq!(restored_caret, searched_caret); - session.execute("resolve.files"); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("Conflicts")); - session.handle(Input::Char('/')); + for (key, action) in [ + ("ctrl+e", "core.file_explorer"), + ("ctrl+o", "core.file_picker"), + ] { + session + .editor_mut() + .bind("normal", key, Some(action.into())) + .unwrap(); + } + session.editor_mut().set_file_explorer_view("list").unwrap(); + for key in ["ctrl+j", "ctrl+n", "down"] { + session.editor_mut().bind("selection", key, None).unwrap(); + } + session + .editor_mut() + .bind("selection", "ctrl+t", Some("core.down".into())) + .unwrap(); + session.handle(Input::Ctrl('e')); + let picker_caret = render(&mut terminal, &mut views, &mut session); + let dock = views.panels.files.unwrap().0; + assert_eq!( + dock, + dock_layout( + workspace(&terminal, &views, session.editor()), + DockSide::Right, + true + ) + .1 + .unwrap() + ); + assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, dock); + assert!(dock.contains(picker_caret)); + assert!(screen_text(&terminal).contains("Explorer · List")); + let selected = session.editor().file_picker().unwrap().selected; + assert_eq!( + session + .editor() + .file_picker() + .unwrap() + .entries() + .nth(selected) + .unwrap() + .path, + session.editor().resolver().unwrap().files[current_file] + .path + .as_path(), + ); + session.handle(Input::Ctrl('t')); + let moved_caret = render(&mut terminal, &mut views, &mut session); + assert_ne!(session.editor().file_picker().unwrap().selected, selected); + assert_eq!(moved_caret, picker_caret); + let selected_row = (dock.y..dock.bottom()) + .find(|&row| line_at(&terminal, dock, row).contains("second.txt")) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(dock.x + 1, selected_row)].bg, + Color::Blue + ); for ch in "second".chars() { session.handle(Input::Char(ch)); } render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("second.txt")); assert!(!screen_text(&terminal).contains("binary.bin")); - session.handle(Input::Escape); - session.execute("resolve.files"); + session.handle(Input::Enter); + render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!( + resolver.files[resolver.current_file].path, + std::path::Path::new("second.txt") + ); + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('u')); + for ch in "first".chars() { + session.handle(Input::Char(ch)); + } + render(&mut terminal, &mut views, &mut session); + let floating = views.panels.files.unwrap().0; + assert_eq!( + floating, + floating_panel(workspace(&terminal, &views, session.editor())) + ); + assert!(screen_text(&terminal).contains("Files · Tree INSERT")); + session.handle(Input::Enter); session.execute("resolve.draft"); + render(&mut terminal, &mut views, &mut session); + assert_eq!( + session.editor().resolver().unwrap().current_file, + current_file + ); + assert!(views.panels.files.is_none()); assert_eq!( operation(), before_browsing, @@ -7535,12 +7597,20 @@ mod tests { .iter() .any(|row| row.position.continuation > 0) ); + session.handle(Input::Home); + let caret = render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!(geometry.regions[1].0.contains(caret)); let content = diff_inner(geometry.regions[1].0); assert!( (content.y..content.bottom()) .filter(|&y| { line_at(&terminal, content, y).contains("wrapped") }) .count() - > 1 + > 1, + "cursor {:?}, source {:?}, wrapped frame:\n{}", + session.editor().cursor(), + session.editor().lines()[session.editor().cursor().row], + screen_text(&terminal), ); let right = diff_inner(geometry.regions[2].0); assert!( diff --git a/tests/resolve.rs b/tests/resolve.rs index 01f5540..838c89f 100755 --- a/tests/resolve.rs +++ b/tests/resolve.rs @@ -199,7 +199,7 @@ fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() let resolver = session.editor().resolver().unwrap(); assert_eq!(resolver.files.len(), 2); assert_eq!( - resolver.files[resolver.selected_file].status, + resolver.files[resolver.current_file].status, ResolverStatus::Resolved ); assert_eq!(