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.
`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
+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_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.
+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()));
}
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();