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!(