feat: close focused docked file lists with pane actions

This commit is contained in:
Valdemar Lorenzen
2026-10-12 15:42:58 +02:00
parent d64704d4eb
commit 1fd5f98256
4 changed files with 1094 additions and 17 deletions
+2
View File
@@ -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. 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. 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 ```ruby
+7 -2
View File
@@ -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_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.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.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.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` | Explicitly discard an exclusively displayed buffer's changes and close its pane | | `: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: Closing a pane gives its space to its sibling:
| Situation | Close pane | Force close pane | | 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 | | 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 | | 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 | | 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. 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. 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. 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.
+791 -15
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -11258,6 +11258,300 @@ end
assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); 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] #[test]
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
let mut editor = Editor::open(None, vec![]).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap();