From 1fd5f98256076ffa0008f6d17e857aa5496eb97b Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 12 Oct 2026 15:42:58 +0200 Subject: [PATCH] feat: close focused docked file lists with pane actions --- docs/configuration.md | 2 + docs/editing.md | 9 +- src/editor.rs | 806 +++++++++++++++++++++++++++++++++++++++++- src/tui.rs | 294 +++++++++++++++ 4 files changed, 1094 insertions(+), 17 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e6adeb8..a726f9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -218,6 +218,8 @@ The diff's changed-file explorer and floating picker use these same tree and sel Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child. +`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation. + Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available. ```ruby diff --git a/docs/editing.md b/docs/editing.md index 572ce71..ae1b7f1 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -190,13 +190,14 @@ Splits can be nested in either direction. The new pane receives focus. Panes sho | `:pane.split_horizontal` | `config.core.pane.split_horizontal` | Split above/below (`_` in Normal) | | `:pane.split_vertical` | `config.core.pane.split_vertical` | Split left/right (`\|` in Normal) | | `:pane.focus_left` / `:pane.focus_down` / `:pane.focus_up` / `:pane.focus_right` | Corresponding `config.core.pane.focus_*` action | Directional focus (Option/Alt+h/j/k/l in Normal) | -| `:pane.close` | `config.core.pane.close` | Close the focused pane and its exclusively displayed buffer, subject to unsaved-change protection | -| `:pane.force_close` | `config.core.pane.force_close` | Explicitly discard an exclusively displayed buffer's changes and close its pane | +| `:pane.close` | `config.core.pane.close` | Close a focused docked file list, otherwise close the focused editor pane subject to unsaved-change protection | +| `:pane.force_close` | `config.core.pane.force_close` | Close a focused docked list without discarding data; otherwise explicitly discard exclusively displayed edits and close the editor pane | Closing a pane gives its space to its sibling: | Situation | Close pane | Force close pane | | --- | --- | --- | +| Focused docked explorer or diff file list | Close only the list and return document focus; retain buffers, edits, tabs, and comparison | Same | | Clean buffer displayed only here | Close pane and buffer | Same | | Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer | | Buffer also displayed in another pane or tab | Close only this pane; retain the buffer and edits | Same | @@ -205,6 +206,10 @@ Closing a pane gives its space to its sibling: There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings. +A focused docked list is a close-action target even when it is the only visible side panel; closing it never quits or closes its buffer. A visible but unfocused dock does not intercept pane-close. The command palette remembers a dock that had focus when it opened for these two close actions, including force-close confirmation and whitespace edits to that command; cancelling or choosing another action clears that target. + +Floating pickers are not pane-close targets. A resolved close/force-close action passes through to the underlying editor pane/tab with its existing dirty-buffer guards; it is not ignored and does not merely dismiss the picker. A surviving tab-owned floating picker remains open until its ordinary Escape/close behavior or owner closure removes it. Printable bindings remain query text while a filter owns input; control-key or control-prefix bindings can invoke pane-close without altering the query. + Pane-focus bindings also work while the ordinary file explorer or picker has focus. Moving to a neighboring pane returns keyboard focus to its document; a floating picker closes, while a docked explorer remains visible. Explicit `tree` or `selection` navigation overrides and disabled keys take precedence over pane-focus bindings. Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge. diff --git a/src/editor.rs b/src/editor.rs index c33c831..c2d5bf4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1416,6 +1416,7 @@ pub struct Editor { line_pending: Option, seek_pending: Option<(bool, usize)>, palette: Option, + pane_close_picker_origin: Option<(usize, usize, Option)>, hover: Option, completion: Option, snippet: Option, @@ -1532,6 +1533,7 @@ impl Editor { line_pending: None, seek_pending: None, palette: None, + pane_close_picker_origin: None, hover: None, completion: None, snippet: None, @@ -1632,6 +1634,7 @@ impl Editor { if id == self.tab_id { return; } + self.pane_close_picker_origin = None; let next = self.tabs.remove(&id).expect("selected tab exists"); self.stash_view(); let previous = Tab { @@ -1778,6 +1781,7 @@ impl Editor { if id == self.pane_id { return; } + self.pane_close_picker_origin = None; let buffer_id = self.panes[&id].buffer_id; self.stash_view(); if self.diff_owner == Some(self.pane_id) { @@ -1817,6 +1821,7 @@ impl Editor { let interaction = (id != focused).then(|| { ( self.palette.take(), + self.pane_close_picker_origin.take(), self.hover.take(), self.completion.take(), self.snippet.take(), @@ -1839,6 +1844,7 @@ impl Editor { if let Some(interaction) = interaction { ( self.palette, + self.pane_close_picker_origin, self.hover, self.completion, self.snippet, @@ -1882,6 +1888,7 @@ impl Editor { self.finish_insert(); } self.palette = None; + self.pane_close_picker_origin = None; self.count = None; self.clear_sequence(); self.g_pending = false; @@ -2049,6 +2056,23 @@ impl Editor { } fn close_pane(&mut self, force: bool) { + self.count = None; + self.clear_sequence(); + let picker_origin = self.pane_close_picker_origin.take().is_some_and(|origin| { + origin + == ( + self.tab_id, + self.pane_id, + self.diff.as_ref().and_then(|diff| diff.comparison_id), + ) + }); + if self.file_picker().is_some_and(|picker| { + (picker.focused || picker_origin) && picker.presentation == FilePresentation::Docked + }) { + self.palette = None; + self.close_file_picker(); + return; + } if self.pane_layout.len() == 1 { if self.tab_count() > 1 { self.close_tab(force); @@ -2481,7 +2505,9 @@ impl Editor { .diff_return_buffer .and_then(|id| self.parked.remove(&id)) { + let palette = self.palette.take(); self.activate(buffer); + self.palette = palette; } self.diff_bound = None; return; @@ -2525,6 +2551,8 @@ impl Editor { .map(|row| (row, column)) }); let writable = focus == Focus::After && diff.after_editable(); + // Binding a comparison side is not a user departure from its palette. + let palette = self.palette.take(); if writable { let path = comparison.root.join(&file.path); let root = comparison.root.canonicalize(); @@ -2552,6 +2580,7 @@ impl Editor { diff.live_buffer = None; diff.live_revision = None; diff.error = Some(format!("Open failed: {error}")); + self.palette = palette; self.bind_diff_buffer(); return; } @@ -2571,6 +2600,7 @@ impl Editor { } } self.diff_bound = Some((key.0, key.1, key.2, self.active.id)); + self.palette = palette; if let Some((row, wanted)) = synchronized { let row = row.min(self.active.lines.len() - 1); let cursor = Cursor { @@ -2700,6 +2730,7 @@ 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) { self.pending_lsp_navigation = None; } @@ -2726,6 +2757,7 @@ impl Editor { } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + self.pane_close_picker_origin = None; if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| { editor.scroll_diff_horizontal(focus, right, width) @@ -2778,7 +2810,10 @@ impl Editor { .as_ref() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.searching || files.view == FileView::List)); - if text_focused && matches!(input, Input::Char(ch) if !ch.is_control()) { + if text_focused + && self.sequence_pending().is_none() + && matches!(input, Input::Char(ch) if !ch.is_control()) + { if !self.handle_selection_binding(&input) { self.diff.as_mut().unwrap().handle(input); } @@ -2840,16 +2875,7 @@ impl Editor { && input != self.leader && !self.has_sequence_prefix(self.binding_mode(), &input) { - let diff = self.diff.as_mut().unwrap(); - diff.files_visible = false; - diff.focus = self - .diff_bound - .map(|bound| bound.2) - .unwrap_or_else(|| diff.first_text_focus()); - diff.normalize_focus(); - if let Some(files) = &mut diff.files { - files.focused = false; - } + self.close_file_picker(); return None; } if files_focused && self.handle_tree_binding(&input) { @@ -3123,6 +3149,7 @@ impl Editor { /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { + self.pane_close_picker_origin = None; self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; @@ -4922,13 +4949,19 @@ impl Editor { let mode = self.binding_mode(); let palette = self.palette.as_mut().expect("open palette receives input"); match input { - Input::Escape | Input::Ctrl('p') => self.palette = None, + Input::Escape | Input::Ctrl('p') => { + self.palette = None; + self.pane_close_picker_origin = None; + } Input::Enter => { let entry = palette .entries .get(palette.selected) .filter(|entry| entry.enabled)?; if let Some(action) = Action::named(mode, &entry.name) { + if !matches!(action, Action::PaneClose | Action::PaneForceClose) { + self.pane_close_picker_origin = None; + } if let Some(core) = CORE_ACTIONS .iter() .find(|core| core.action == action && core.palette_prompt) @@ -4939,6 +4972,7 @@ impl Editor { self.palette = None; return self.act(action); } + self.pane_close_picker_origin = None; self.active.view.command_line = format!("{} ", entry.name); self.palette = None; if self.diff.is_some() && !self.diff_interacting() { @@ -4964,6 +4998,7 @@ impl Editor { } fn open_file_picker(&mut self, presentation: FilePresentation) { + self.pane_close_picker_origin = None; let view = match presentation { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, @@ -5039,13 +5074,27 @@ impl Editor { } } + fn close_file_picker(&mut self) { + self.pane_close_picker_origin = None; + if let Some(diff) = &mut self.diff { + diff.files_visible = false; + diff.focus = self + .diff_bound + .map(|bound| bound.2) + .unwrap_or_else(|| diff.first_text_focus()); + diff.normalize_focus(); + } else { + self.file_picker = None; + } + } + fn handle_file_picker(&mut self, input: Input) { let picker = self .file_picker .as_mut() .expect("focused picker receives input"); match input { - Input::Ctrl('w') => self.file_picker = None, + Input::Ctrl('w') => self.close_file_picker(), Input::Escape if picker.view == FileView::Tree && picker.searching => { picker.finish_search() } @@ -5086,6 +5135,19 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; + if self.palette.is_none() + && self.active.view.mode != Mode::Command + && !matches!( + self.bindings + .get(&self.binding_mode()) + .and_then(|keys| keys.get(&input)), + Some(Binding::Action( + Action::Submit | Action::PaneClose | Action::PaneForceClose + )) + ) + { + self.pane_close_picker_origin = None; + } self.bind_diff_buffer(); let invocation = if self.diff.is_some() { self.handle_diff(input) @@ -5238,6 +5300,8 @@ impl Editor { | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight + | Action::PaneClose + | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext @@ -5438,6 +5502,14 @@ impl Editor { } fn act(&mut self, action: Action) -> Option { + if (self.active.view.mode != Mode::Command || action == Action::Normal) + && !matches!( + action, + Action::Palette | Action::Submit | Action::PaneClose | Action::PaneForceClose + ) + { + self.pane_close_picker_origin = None; + } if action == Action::Normal && self.diff_prompt_mode.is_some() { self.finish_diff_prompt(); self.count = None; @@ -5489,6 +5561,18 @@ impl Editor { } Action::SnippetPrevious => self.navigate_snippet(false), Action::Palette => { + self.pane_close_picker_origin = self + .file_picker() + .filter(|picker| { + picker.focused && picker.presentation == FilePresentation::Docked + }) + .map(|_| { + ( + self.tab_id, + self.pane_id, + self.diff.as_ref().and_then(|diff| diff.comparison_id), + ) + }); self.blur_file_picker(); self.palette = Some(Palette { query: String::new(), @@ -6213,6 +6297,7 @@ impl Editor { if let Some(entry) = core_command(name) { return self.execute_action(entry, args); } + self.pane_close_picker_origin = None; if self.diff.is_some() && !self.diff_editing() { self.count = None; self.clear_sequence(); @@ -6253,6 +6338,9 @@ impl Editor { } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { + if !matches!(entry.action, Action::PaneClose | Action::PaneForceClose) { + self.pane_close_picker_origin = None; + } self.finish_diff_prompt(); if matches!( entry.action, @@ -6271,6 +6359,7 @@ impl Editor { | Action::TabForceClose ) { if !args.is_empty() { + self.pane_close_picker_origin = None; self.set_message(format!( "The {} command takes no arguments", entry.ex.unwrap() @@ -10116,6 +10205,435 @@ mod tests { assert!(editor.should_quit()); } + fn close_pane_using(editor: &mut Editor, binding: Option<&str>, force: bool) { + let command = if force { + "pane.force_close" + } else { + "pane.close" + }; + if let Some(binding) = binding { + editor + .bind("normal", binding, Some(format!("core.{command}"))) + .unwrap(); + for key in binding.split_whitespace() { + editor.handle(parse_key(key).unwrap()); + } + } else { + editor.execute(command); + } + } + + #[test] + fn pane_close_targets_focused_docked_lists_without_closing_dirty_documents_or_tabs() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + let path = directory.path("dir/file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for another_tab in [false, true] { + for force in [false, true] { + for binding in [ + None, + Some("ctrl+x"), + Some("alt+x"), + Some("ctrl+alt+x"), + Some("delete"), + Some("ctrl+w c"), + ] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + if another_tab { + editor.execute("tab.new"); + } + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + if comparison { + working_diff(&mut editor, &directory.0, &["dir/file.txt"]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + } + let owner = (editor.tab_id(), editor.pane_id(), editor.buffer_id()); + let tabs = editor.tab_count(); + let buffers = editor.buffer_count(); + let revision = editor.text_revision(); + let targets = editor.active.view.targets.clone(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + editor.act(Action::FileExplorer); + type_text(&mut editor, "ifile"); + editor.handle(Input::Escape); + let selected = editor.file_picker().unwrap().selected; + let rows = editor + .file_picker() + .unwrap() + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(); + + close_pane_using(&mut editor, binding, force); + + assert!(!editor.should_quit()); + assert_eq!(editor.sequence_pending(), None); + assert!(editor.file_picker().is_none()); + assert_eq!( + (editor.tab_id(), editor.pane_id(), editor.buffer_id()), + owner + ); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), tabs); + assert_eq!(editor.buffer_count(), buffers); + assert_eq!(editor.lines(), ["Xbase"]); + assert!(editor.dirty()); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.view.targets, targets); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + if comparison { + let diff = editor.diff().unwrap(); + assert_eq!(diff.comparison_id, comparison_id); + assert_eq!(diff.focus, Focus::After); + assert!(!diff.files_visible); + assert!(!diff.files.as_ref().unwrap().focused); + editor.act(Action::FileExplorer); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, "file"); + assert_eq!(picker.selected, selected); + assert_eq!( + picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(), + rows + ); + editor.handle(Input::Ctrl('w')); + if binding == Some("ctrl+w c") { + assert!(editor.file_picker().is_some()); + assert!(editor.sequence_pending().is_some()); + editor.handle(Input::Char('c')); + } + assert!(editor.file_picker().is_none()); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + } else { + editor.act(Action::FileExplorer); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('w')); + if binding == Some("ctrl+w c") { + assert!(editor.file_picker().is_some()); + assert!(editor.sequence_pending().is_some()); + editor.handle(Input::Char('c')); + } + assert!(editor.file_picker().is_none()); + } + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["base"]); + } + } + } + } + } + + #[test] + fn pane_close_does_not_target_unfocused_docked_lists() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.execute("pane.split_vertical"); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + editor.act(Action::FileExplorer); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.lines(), ["Xbase"]); + assert!(editor.dirty()); + assert!(editor.diff().is_none()); + if comparison { + assert!(editor.file_picker().is_none()); + } else { + assert!(!editor.file_picker().unwrap().focused); + } + } + } + + #[test] + fn floating_pane_close_closes_underlying_panes_and_retains_shared_dirty_buffers() { + let directory = Directory::new(); + let base = directory.path("base.txt"); + let other = directory.path("other.txt"); + fs::write(&base, "base").unwrap(); + fs::write(&other, "other").unwrap(); + for comparison in [false, true] { + for shared in [false, true] { + for force in [false, true] { + let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + if shared { + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + } + editor.execute("pane.split_vertical"); + if !shared { + editor.execute(&format!("edit {}", other.display())); + } + let source = editor.buffer_id(); + if comparison { + let file = if shared { "base.txt" } else { "other.txt" }; + working_diff(&mut editor, &directory.0, &[file]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + } + editor.act(Action::FilePicker); + type_text(&mut editor, "txt"); + close_pane_using(&mut editor, Some("alt+x"), force); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.has_buffer(source), shared); + assert_eq!(editor.lines(), [if shared { "Xbase" } else { "base" }]); + assert_eq!(editor.dirty(), shared); + assert!(editor.diff().is_none()); + if comparison { + assert!(editor.file_picker().is_none()); + } else { + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.presentation, FilePresentation::Floating); + assert_eq!(picker.query, "txt"); + } + } + } + } + } + + #[test] + fn floating_pane_close_refuses_dirty_loss_then_force_closes_without_dismissing_first() { + let directory = Directory::new(); + let base = directory.path("base.txt"); + let other = directory.path("other.txt"); + fs::write(&base, "base").unwrap(); + fs::write(&other, "other").unwrap(); + for comparison in [false, true] { + for binding in [ + None, + Some("ctrl+x"), + Some("alt+x"), + Some("ctrl+alt+x"), + Some("delete"), + Some("ctrl+w c"), + ] { + let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.execute("pane.split_vertical"); + editor.execute(&format!("edit {}", other.display())); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + let source = editor.buffer_id(); + if comparison { + working_diff(&mut editor, &directory.0, &["other.txt"]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + } + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + let revision = editor.text_revision(); + let targets = editor.active.view.targets.clone(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + editor.act(Action::FilePicker); + type_text(&mut editor, "other"); + let selected = editor.file_picker().unwrap().selected; + + close_pane_using(&mut editor, binding, false); + + assert_eq!(editor.pane_layout().len(), 2); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.buffer_id(), source); + assert!(!editor.should_quit()); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.lines(), ["Xother"]); + assert!(editor.dirty()); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.view.targets, targets); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert!(picker.searching); + assert_eq!(picker.presentation, FilePresentation::Floating); + assert_eq!(picker.query, "other"); + assert_eq!(picker.selected, selected); + assert!(editor.message().contains("pane.force_close")); + assert_eq!( + editor.diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + + close_pane_using(&mut editor, binding, true); + + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.has_buffer(source)); + assert_eq!(editor.lines(), ["base"]); + assert!(!editor.should_quit()); + assert!(editor.diff().is_none()); + assert_eq!(editor.file_picker().is_some(), !comparison); + } + } + } + + #[test] + fn floating_pane_close_reaches_tab_and_last_pane_guards() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for another_tab in [false, true] { + for dirty in [false, true] { + let mut editor = project_editor(&directory); + if another_tab { + editor.execute("tab.new"); + } + editor.execute(&format!("edit {}", path.display())); + let source = editor.buffer_id(); + let tab = editor.tab_id(); + if dirty { + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + } + editor.act(Action::FilePicker); + type_text(&mut editor, "file"); + close_pane_using(&mut editor, Some("ctrl+x"), false); + if dirty { + assert_eq!(editor.tab_id(), tab); + assert_eq!(editor.buffer_id(), source); + assert!(editor.has_buffer(source)); + assert!(!editor.should_quit()); + assert!(editor.file_picker().unwrap().focused); + assert_eq!(editor.file_picker().unwrap().query, "file"); + close_pane_using(&mut editor, Some("ctrl+x"), true); + } + assert_eq!(editor.should_quit(), !another_tab); + assert_eq!(editor.tab_count(), 1); + if another_tab { + assert_ne!(editor.tab_id(), tab); + assert!(!editor.has_buffer(source)); + assert!(editor.file_picker().is_none()); + } else { + assert!(editor.file_picker().unwrap().focused); + } + } + } + } + + #[test] + fn pane_close_bindings_preserve_picker_filter_and_escape_priority() { + let directory = Directory::new(); + fs::write(directory.path("xz.txt"), "base").unwrap(); + for comparison in [false, true] { + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for view in ["tree", "list"] { + let mut editor = project_editor(&directory); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + if comparison { + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + } + editor + .bind("normal", "x", Some("core.pane.close".into())) + .unwrap(); + editor + .bind("normal", "z", Some("core.pane.force_close".into())) + .unwrap(); + editor.set_file_picker_view(view).unwrap(); + editor.set_file_explorer_view(view).unwrap(); + editor.act(match presentation { + FilePresentation::Floating => Action::FilePicker, + FilePresentation::Docked => Action::FileExplorer, + }); + if presentation == FilePresentation::Docked && view == "tree" { + editor.handle(Input::Char('i')); + } + let text = editor.lines().to_vec(); + type_text(&mut editor, "xz"); + assert_eq!(editor.file_picker().unwrap().query, "xz"); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.lines(), text); + editor.handle(Input::Escape); + if view == "tree" { + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + } + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + } + } + assert!(editor.has_buffer(1)); + assert!(!editor.should_quit()); + } + } + } + } + + #[test] + fn pane_close_bindings_yield_to_ordinary_tree_and_selection_overrides() { + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "a").unwrap(); + fs::write(directory.path("b.txt"), "b").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for context in ["tree", "selection"] { + for target in [None, Some("core.down")] { + for force in [false, true] { + let mut editor = project_editor(&directory); + editor + .bind( + "normal", + "ctrl+x", + Some(if force { + "core.pane.force_close".into() + } else { + "core.pane.close".into() + }), + ) + .unwrap(); + editor + .bind(context, "ctrl+x", target.map(str::to_owned)) + .unwrap(); + editor.act(match presentation { + FilePresentation::Floating => Action::FilePicker, + FilePresentation::Docked => Action::FileExplorer, + }); + editor.handle(Input::Ctrl('x')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.selected, usize::from(target.is_some())); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.should_quit()); + } + } + } + } + } + #[test] fn render_swaps_restore_focus_on_panics_and_isolate_caret_reveal_requests() { let mut editor = editor(); @@ -10275,6 +10793,265 @@ mod tests { assert!(editor.should_quit()); } + #[test] + fn palette_pane_close_keeps_focused_dock_target_through_force_confirmation() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for force in [false, true] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + let source = editor.buffer_id(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + let owner = (editor.tab_id(), editor.pane_id()); + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + editor.act(Action::FileExplorer); + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + assert!(!editor.file_picker().unwrap().focused); + let buffers = editor.buffer_count(); + type_text( + &mut editor, + if force { + "core.pane.force_close" + } else { + "core.pane.close" + }, + ); + editor.handle(Input::Enter); + if force { + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "pane.force_close "); + assert!(editor.file_picker().is_some()); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(!editor.should_quit()); + editor.handle(Input::Char(' ')); + editor.handle(Input::Backspace); + editor.handle(Input::Backspace); + assert!(editor.pane_close_picker_origin.is_some()); + editor.handle(Input::Enter); + } + assert_eq!((editor.tab_id(), editor.pane_id()), owner); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.buffer_count(), buffers); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + assert!(editor.file_picker().is_none()); + assert_eq!(editor.pane_close_picker_origin, None); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["Xbase"]); + assert!(source.dirty()); + assert_eq!(source.text_revision, revision); + assert_eq!((source.undo.len(), source.redo.len()), history); + assert_eq!( + editor.diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + } + } + } + + #[test] + fn palette_pane_close_origin_clears_on_cancel_edit_or_unrelated_action() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for cancellation in [ + "escape", + "toggle", + "unrelated", + "force_escape", + "force_other_command", + "invalid_args", + ] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + editor.act(Action::FileExplorer); + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + match cancellation { + "escape" => { + editor.handle(Input::Escape); + } + "toggle" => { + editor.handle(Input::Ctrl('p')); + } + "unrelated" => { + type_text(&mut editor, "core.word_wrap.toggle"); + editor.handle(Input::Enter); + } + "force_escape" | "force_other_command" => { + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert!(editor.pane_close_picker_origin.is_some()); + if cancellation == "force_escape" { + editor.handle(Input::Escape); + } else { + for _ in 0..editor.command_line().chars().count() { + editor.handle(Input::Backspace); + } + type_text(&mut editor, "word_wrap.toggle"); + editor.handle(Input::Enter); + } + } + "invalid_args" => { + editor.execute("pane.close unexpected"); + } + _ => unreachable!(), + } + assert_eq!(editor.pane_close_picker_origin, None, "{cancellation}"); + assert!( + editor.file_picker().is_some(), + "comparison={comparison}, cancellation={cancellation}" + ); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit(), "{cancellation}"); + assert!( + editor.file_picker().is_some(), + "comparison={comparison}, cancellation={cancellation}" + ); + } + } + } + + #[test] + fn document_and_floating_palette_origins_never_intercept_pane_close() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for presentation in [FilePresentation::Docked, FilePresentation::Floating] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + let source = editor.buffer_id(); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + editor.act(match presentation { + FilePresentation::Docked => Action::FileExplorer, + FilePresentation::Floating => Action::FilePicker, + }); + if presentation == FilePresentation::Docked { + editor.handle(Input::Escape); + } + editor.handle(Input::Ctrl('p')); + assert_eq!(editor.pane_close_picker_origin, None); + assert!(editor.palette().is_some()); + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["dirtybase"]); + assert!(source.dirty()); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!( + editor.file_picker().is_some(), + presentation == FilePresentation::Docked + ); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert!(!editor.should_quit()); + editor.handle(Input::Enter); + assert!(editor.should_quit()); + } + } + } + + #[test] + fn palette_pane_close_origin_is_render_local_and_expires_on_owner_changes() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "base").unwrap(); + for owner_change in ["none", "pane", "tab", "hidden", "floating"] { + let mut editor = project_editor(&directory); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + editor.execute("pane.split_vertical"); + editor.act(Action::FileExplorer); + editor.handle(Input::Ctrl('p')); + let origin = editor.pane_close_picker_origin; + assert!(origin.is_some()); + editor.with_render_pane(1, |pane| { + assert!(pane.palette().is_none()); + assert_eq!(pane.pane_close_picker_origin, None); + }); + assert_eq!(editor.pane_close_picker_origin, origin); + match owner_change { + "none" => {} + "pane" => editor.select_pane(1), + "tab" => { + editor.execute("tab.new"); + } + "hidden" => editor.file_picker = None, + "floating" => { + editor.file_picker.as_mut().unwrap().presentation = FilePresentation::Floating; + } + _ => unreachable!(), + } + if owner_change == "none" { + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.file_picker().is_none()); + } else { + editor.execute("pane.force_close"); + assert_eq!( + editor.pane_layout().len(), + if owner_change == "tab" { 2 } else { 1 } + ); + assert_eq!(editor.pane_close_picker_origin, None); + assert_eq!(editor.lines(), ["dirty"]); + } + assert!(!editor.should_quit()); + } + } + + #[test] + fn palette_pane_close_rejects_a_replaced_comparison_origin() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + let source = editor.buffer_id(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.act(Action::FileExplorer); + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + let diff = editor.diff.as_mut().unwrap(); + diff.comparison_id = diff.comparison_id.map(|id| id + 1); + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), 1); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["dirtybase"]); + assert!(source.dirty()); + assert!(editor.file_picker().is_some()); + assert_eq!(editor.pane_close_picker_origin, None); + } + #[test] fn closing_comparison_checks_the_dirty_working_buffer_not_its_readonly_snapshot() { let directory = Directory::new(); @@ -10286,8 +11063,7 @@ mod tests { editor.execute("pane.focus_left"); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::Before; - editor.bind_diff_buffer(); + editor.handle(Input::Char('\t')); assert!(!editor.dirty()); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 2); diff --git a/src/tui.rs b/src/tui.rs index 3cdfba6..2e67bbe 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -11258,6 +11258,300 @@ end assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); } + const PANE_CLOSE_CONFIG: &str = r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_explorer_side = "right" + config.file_picker_view = "tree" + config.bind "ctrl+o", config.core.file_picker + config.bind "ctrl+x", config.core.pane.close + config.bind "ctrl+y", config.core.pane.force_close +end +"#; + + #[test] + fn bound_pane_close_reclaims_focused_dock_without_losing_dirty_source_or_diff() { + let directory = Directory::new(); + let path = directory.file("source.txt", "SOURCE\nsecond"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + for key in ['x', 'y'] { + let mut session = + EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let source = session.editor().buffer_id(); + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + let text = session.editor().lines().to_vec(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } else { + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + } + let pane = session.editor().pane_id(); + let tab = session.editor().tab_id(); + let buffers = session.editor().buffer_count(); + let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); + let picker = session.editor().file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.presentation, FilePresentation::Docked); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let dock = views.panels.files.unwrap().0; + let workspace = views.rects[0].1; + let regions = views.panels.diff.as_ref().map(|geometry| geometry.regions); + assert_eq!(workspace.width + dock.width, 120); + assert!(dock.contains(terminal.get_cursor_position().unwrap())); + + session.handle( + translate(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)).unwrap(), + ); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit(), "{comparison} Ctrl+{key}"); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().pane_layout().len(), 1); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().buffer_count(), buffers); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects[0].1.width, 120); + assert!(!screen_text(&terminal).contains("Explorer")); + assert!( + session + .editor() + .buffers() + .find(|buffer| buffer.id == source) + .unwrap() + .dirty + ); + assert_eq!( + session + .editor() + .documents() + .find(|document| document.id == source) + .unwrap() + .lines, + text + ); + let (caret_area, caret) = views.panels.caret.unwrap(); + assert!(caret_area.contains(caret)); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + if comparison { + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.comparison_id, comparison_id); + assert!(!diff.files_visible); + assert!(!diff.files.as_ref().unwrap().focused); + let expanded = views.panels.diff.as_ref().unwrap().regions; + assert_eq!(expanded[0].0, Rect::default()); + for index in [1, 2] { + assert!(expanded[index].0.width > regions.unwrap()[index].0.width); + assert_eq!( + terminal.backend().buffer()[(expanded[index].0.x, expanded[index].0.y)] + .symbol(), + "┌" + ); + } + let screen = screen_text(&terminal); + assert!(screen.contains("Before") && screen.contains("After")); + assert!(screen.contains("OLD") && screen.contains("NEW")); + session.execute("diff.close"); + } + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().lines(), text); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["SOURCE", "second"]); + assert!(!session.editor().dirty()); + } + } + } + + #[test] + fn bound_pane_close_with_unfocused_dock_removes_editor_leaf_not_the_list() { + let directory = Directory::new(); + let path = directory.file("source.txt", "SOURCE"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + let mut session = + EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let root = session.editor().pane_id(); + let source = session.editor().buffer_id(); + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + session.execute("pane.split_vertical"); + let closing = session.editor().pane_id(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } else { + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + } + session.handle(Input::Char('\t')); + assert!(!session.editor().file_picker().unwrap().focused); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.rects.len(), 2); + let dock = views.panels.files.unwrap().0; + session.handle(Input::Ctrl('x')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit()); + assert_eq!(session.editor().pane_id(), root); + assert!(!session.editor().pane_layout().contains(closing)); + assert_eq!(views.rects.len(), 1); + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().lines(), ["DIRTY SOURCE"]); + assert!(session.editor().dirty()); + assert!(session.editor().diff().is_none()); + assert!(!screen_text(&terminal).contains("Before")); + if comparison { + assert!(views.panels.files.is_none()); + assert_eq!(views.rects[0].1.width, 120); + } else { + assert!(!session.editor().file_picker().unwrap().focused); + assert_eq!(views.panels.files.unwrap().0, dock); + assert_eq!(views.rects[0].1.width + dock.width, 120); + assert!(screen_text(&terminal).contains("Explorer")); + } + assert!( + views.rects[0] + .1 + .contains(terminal.get_cursor_position().unwrap()) + ); + } + } + + #[test] + fn bound_pane_close_under_floating_picker_obeys_dirty_guard_force_and_escape() { + let directory = Directory::new(); + let source = directory.file("source.txt", "SURVIVING DOCUMENT"); + let owner = directory.file("owner.txt", "POPUP OWNER"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + for dirty in [false, true] { + let mut session = + EditorSession::open(Some(source.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let root = session.editor().pane_id(); + let source_buffer = session.editor().buffer_id(); + session.execute("tab.new"); + session.execute(&format!("edit {}", owner.display())); + let owner_buffer = session.editor().buffer_id(); + let owner_pane = session.editor().pane_id(); + let owner_tab = session.editor().tab_id(); + if dirty { + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + } + let text = session.editor().lines().to_vec(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } + let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); + session.handle(Input::Ctrl('o')); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let popup = views.panels.files.unwrap().0; + assert_eq!( + session.editor().file_picker().unwrap().presentation, + FilePresentation::Floating + ); + assert!(popup.contains(terminal.get_cursor_position().unwrap())); + session.handle(Input::Ctrl('x')); + if dirty { + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().tab_count(), 2); + assert_eq!(session.editor().tab_id(), owner_tab); + assert_eq!(session.editor().pane_id(), owner_pane); + assert!(!session.editor().should_quit()); + assert!(session.editor().message().contains("Unsaved changes")); + assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(views.panels.files.unwrap().0, popup); + assert!(popup.contains(terminal.get_cursor_position().unwrap())); + assert_eq!( + session + .editor() + .documents() + .find(|document| document.id == owner_buffer) + .unwrap() + .lines, + text + ); + session.handle(Input::Escape); + assert!(session.editor().file_picker().unwrap().focused); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(session.editor().pane_id(), owner_pane); + assert_eq!(session.editor().tab_count(), 2); + assert_eq!( + session.editor().diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + if comparison { + assert!(screen_text(&terminal).contains("Before")); + } else { + assert!(screen_text(&terminal).contains("DIRTY POPUP OWNER")); + } + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('y')); + } + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit()); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().pane_id(), root); + assert_eq!(session.editor().buffer_id(), source_buffer); + assert!(!session.editor().has_buffer(owner_buffer)); + assert_eq!(session.editor().lines(), ["SURVIVING DOCUMENT"]); + assert!(session.editor().diff().is_none()); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects.len(), 1); + assert_eq!(views.rects[0].1.width, 120); + assert!(screen_text(&terminal).contains("SURVIVING DOCUMENT")); + let (caret_area, caret) = views.panels.caret.unwrap(); + assert!(caret_area.contains(caret)); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + } + } + } + #[test] fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { let mut editor = Editor::open(None, vec![]).unwrap();