feat: close focused docked file lists with pane actions
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+294
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user