feat: add floating and docked searchable file picker
This commit is contained in:
Generated
+119
@@ -2,6 +2,15 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -29,6 +38,16 @@ version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
@@ -73,6 +92,31 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@@ -199,6 +243,19 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -233,6 +290,22 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
@@ -541,6 +614,23 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -575,6 +665,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -797,6 +896,7 @@ name = "vamp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"ignore",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -804,6 +904,16 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -826,6 +936,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
ignore = "0.4"
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -20,6 +20,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Up/Down or Ctrl+k/Ctrl+n select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal, Visual | Space o opens floating file search; Space e opens/focuses the docked explorer |
|
||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
@@ -89,6 +90,27 @@ IDs start at 1 and are never renumbered or reused. Closing the last buffer creat
|
||||
|
||||
Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
|
||||
### File picker and explorer
|
||||
|
||||
Space o opens a floating picker; Space e opens or focuses the docked explorer. Both use the same searchable project-relative file list. There is one instance: invoking the other action changes its presentation while retaining the query and selection. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
|
||||
The dock defaults to the right of the editor workspace. Set `config.file_explorer_side = "left"` (or `"right"`, `"top"`, `"bottom"`) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||
|
||||
| File-search input | Behavior |
|
||||
| --- | --- |
|
||||
| Type / Backspace | Case-insensitive whitespace-token filtering / remove one whole grapheme |
|
||||
| Up/Down or Ctrl+k/Ctrl+n | Wrap through matching files |
|
||||
| Enter | Open the selected file, reusing an existing buffer without reloading hidden edits |
|
||||
| Esc | Close floating search; return to editor focus while retaining a dock |
|
||||
| Tab | Return to editor focus from a dock |
|
||||
| Ctrl+w | Close either presentation |
|
||||
| Ctrl+r | Rescan the project while retaining the query |
|
||||
| Ctrl+p | Open the command palette, closing floating search or blurring a dock |
|
||||
|
||||
Opening a file from a dock leaves it visible and returns focus to editing. Space e refocuses it. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared selections report an error and keep focus without changing the active buffer.
|
||||
|
||||
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable files with an explicit incomplete-scan warning. A failed refresh keeps the prior list. There is no background watcher: refresh explicitly after external file changes. The explorer is a searchable path list, not an expandable directory tree.
|
||||
|
||||
### Command palette
|
||||
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||
@@ -111,9 +133,10 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
|
||||
|
||||
## Module seams
|
||||
|
||||
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
||||
One crate, five modules, no workspace or terminal-coordinate types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata and command-palette state/filtering, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `files`: owns the gitignore-aware catalog, authoritative OS paths, query filtering, navigation, and scan/error state shared by floating and docked presentations.
|
||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
@@ -125,6 +148,7 @@ Frontends inspect `palette()` for the optional read-only query, filtered command
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for optional query/results, presentation, focus, and errors, and `file_explorer_side()` for logical docking preference. They allocate rectangles and render; file filtering, focus transitions, and selected-file opening remain backend-owned.
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
@@ -143,6 +167,7 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.file_explorer_side = "right"
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
@@ -159,6 +184,7 @@ end
|
||||
```
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; available modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Omitting `mode` selects Normal. The leader is session-wide, defaults to `"space"`, and is applied before configured bindings.
|
||||
`config.file_explorer_side` is session-wide and defaults to `"right"`; the other choices are `"left"`, `"top"`, and `"bottom"`. An invalid side reports a notice and retains the default, while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
@@ -177,6 +203,7 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Normal, Insert | `delete` |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.file_explorer_side = "right"
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
|
||||
+545
-19
@@ -1,3 +1,4 @@
|
||||
use crate::files::{DockSide, FilePicker, FilePresentation};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -202,6 +203,8 @@ enum Action {
|
||||
SkipOccurrence,
|
||||
SelectAll,
|
||||
Palette,
|
||||
FilePicker,
|
||||
FileExplorer,
|
||||
}
|
||||
|
||||
// Names and mode eligibility are shared by configured bindings and the palette.
|
||||
@@ -353,6 +356,16 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
Mode::Visual,
|
||||
],
|
||||
),
|
||||
(
|
||||
"core.file_picker",
|
||||
Action::FilePicker,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.file_explorer",
|
||||
Action::FileExplorer,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
];
|
||||
|
||||
impl Action {
|
||||
@@ -625,11 +638,18 @@ struct Buffer {
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
fn open(id: usize, path: Option<PathBuf>, identity: Option<PathBuf>) -> io::Result<Self> {
|
||||
fn open(
|
||||
id: usize,
|
||||
path: Option<PathBuf>,
|
||||
identity: Option<PathBuf>,
|
||||
allow_missing: bool,
|
||||
) -> io::Result<Self> {
|
||||
let text = match &path {
|
||||
Some(path) => match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => {
|
||||
String::new()
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
None => String::new(),
|
||||
@@ -683,6 +703,9 @@ pub struct Editor {
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
palette: Option<Palette>,
|
||||
project_root: PathBuf,
|
||||
file_picker: Option<FilePicker>,
|
||||
file_explorer_side: DockSide,
|
||||
count: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -690,7 +713,7 @@ impl Editor {
|
||||
pub fn open(path: Option<PathBuf>, capabilities: Vec<String>) -> io::Result<Self> {
|
||||
let identity = path.as_deref().map(path_identity).transpose()?;
|
||||
let mut editor = Self {
|
||||
active: Buffer::open(1, path, identity)?,
|
||||
active: Buffer::open(1, path, identity, true)?,
|
||||
parked: BTreeMap::new(),
|
||||
next_buffer_id: 2,
|
||||
command_line: String::new(),
|
||||
@@ -705,6 +728,9 @@ impl Editor {
|
||||
leader_suffix: Vec::new(),
|
||||
leader_display: String::new(),
|
||||
palette: None,
|
||||
project_root: std::env::current_dir()?,
|
||||
file_picker: None,
|
||||
file_explorer_side: DockSide::Right,
|
||||
count: None,
|
||||
};
|
||||
editor
|
||||
@@ -727,6 +753,10 @@ impl Editor {
|
||||
for (mode, key, target) in [
|
||||
("normal", "leader space", "core.palette"),
|
||||
("visual", "leader space", "core.palette"),
|
||||
("normal", "leader o", "core.file_picker"),
|
||||
("visual", "leader o", "core.file_picker"),
|
||||
("normal", "leader e", "core.file_explorer"),
|
||||
("visual", "leader e", "core.file_explorer"),
|
||||
("normal", "leader w", "w"),
|
||||
("normal", "leader b n", "bnext"),
|
||||
("normal", "leader b p", "bprev"),
|
||||
@@ -779,6 +809,25 @@ impl Editor {
|
||||
pub fn palette(&self) -> Option<&Palette> {
|
||||
self.palette.as_ref()
|
||||
}
|
||||
|
||||
pub fn file_picker(&self) -> Option<&FilePicker> {
|
||||
self.file_picker.as_ref()
|
||||
}
|
||||
|
||||
pub fn file_explorer_side(&self) -> DockSide {
|
||||
self.file_explorer_side
|
||||
}
|
||||
|
||||
pub fn set_file_explorer_side(&mut self, side: &str) -> Result<(), String> {
|
||||
self.file_explorer_side = match side.to_ascii_lowercase().as_str() {
|
||||
"left" => DockSide::Left,
|
||||
"right" => DockSide::Right,
|
||||
"top" => DockSide::Top,
|
||||
"bottom" => DockSide::Bottom,
|
||||
_ => return Err(format!("Invalid file explorer side: {side:?}")),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
/// Pending decimal prefix for the next Normal/Visual motion.
|
||||
pub fn count(&self) -> Option<usize> {
|
||||
self.count
|
||||
@@ -1169,7 +1218,92 @@ impl Editor {
|
||||
None
|
||||
}
|
||||
|
||||
fn open_file_picker(&mut self, presentation: FilePresentation) {
|
||||
if let Some(picker) = &mut self.file_picker {
|
||||
picker.presentation = presentation;
|
||||
picker.focused = true;
|
||||
picker.clear_error();
|
||||
} else {
|
||||
match FilePicker::scan(self.project_root.clone(), presentation) {
|
||||
Ok(picker) => self.file_picker = Some(picker),
|
||||
Err(error) => {
|
||||
self.set_message(format!("File scan failed: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.palette = None;
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
}
|
||||
|
||||
fn blur_file_picker(&mut self) {
|
||||
match self.file_picker.as_mut() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
_ => 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::Escape => self.blur_file_picker(),
|
||||
Input::Ctrl('p') => {
|
||||
self.act(Action::Palette);
|
||||
}
|
||||
Input::Char('\t') if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => picker.navigate(false),
|
||||
Input::Down | Input::Ctrl('n') => picker.navigate(true),
|
||||
Input::Ctrl('r') => picker.refresh(),
|
||||
Input::Backspace => picker.backspace(),
|
||||
Input::Char(ch) if !ch.is_control() => {
|
||||
picker.query.push(ch);
|
||||
picker.refilter();
|
||||
}
|
||||
Input::Enter => {
|
||||
picker.clear_error();
|
||||
let Some(path) = picker
|
||||
.entries()
|
||||
.nth(picker.selected)
|
||||
.map(|entry| picker.root.join(&entry.path))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match self.edit_buffer(path, false) {
|
||||
Ok(()) => self.blur_file_picker(),
|
||||
Err(error) => {
|
||||
self.file_picker
|
||||
.as_mut()
|
||||
.expect("failed open retains picker")
|
||||
.error = Some(format!("Open failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||||
if self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
self.handle_file_picker(input);
|
||||
return None;
|
||||
}
|
||||
if self.palette.is_some() {
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
@@ -1292,6 +1426,7 @@ impl Editor {
|
||||
self.clear_leader();
|
||||
match action {
|
||||
Action::Palette => {
|
||||
self.blur_file_picker();
|
||||
self.palette = Some(Palette {
|
||||
query: String::new(),
|
||||
entries: Vec::new(),
|
||||
@@ -1299,6 +1434,8 @@ impl Editor {
|
||||
});
|
||||
self.refresh_palette();
|
||||
}
|
||||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||||
Action::Insert => self.begin_insert(),
|
||||
Action::InsertFirstNonblank | Action::AppendLineEnd => {
|
||||
if self.active.mode == Mode::Visual {
|
||||
@@ -1548,29 +1685,25 @@ impl Editor {
|
||||
.map_or(id, |(id, _)| *id)
|
||||
}
|
||||
|
||||
fn edit_buffer(&mut self, path: PathBuf) {
|
||||
let identity = match path_identity(&path) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
self.set_message(format!("Open failed: {error}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> {
|
||||
let identity = path_identity(&path)?;
|
||||
let loaded = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| buffer.identity.as_ref() == Some(&identity))
|
||||
.map(|buffer| buffer.id);
|
||||
if let Some(id) = loaded {
|
||||
self.switch_buffer(id);
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
match Buffer::open(self.next_buffer_id, Some(path), Some(identity)) {
|
||||
Ok(buffer) => {
|
||||
let buffer = Buffer::open(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
Some(identity),
|
||||
allow_missing,
|
||||
)?;
|
||||
self.next_buffer_id += 1;
|
||||
self.activate(buffer);
|
||||
}
|
||||
Err(error) => self.set_message(format!("Open failed: {error}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_buffer(&mut self, id: usize, force: bool) {
|
||||
@@ -1592,7 +1725,7 @@ impl Editor {
|
||||
if id == self.active.id {
|
||||
let next = self.adjacent_buffer(true);
|
||||
let replacement = if next == id {
|
||||
let buffer = Buffer::open(self.next_buffer_id, None, None)
|
||||
let buffer = Buffer::open(self.next_buffer_id, None, None, true)
|
||||
.expect("an unnamed empty buffer is valid");
|
||||
self.next_buffer_id += 1;
|
||||
buffer
|
||||
@@ -1614,7 +1747,11 @@ impl Editor {
|
||||
fn buffer_command(&mut self, name: &str, args: &str) -> bool {
|
||||
match name {
|
||||
"edit" if args.is_empty() => self.set_message("Usage: :edit path"),
|
||||
"edit" => self.edit_buffer(PathBuf::from(args)),
|
||||
"edit" => {
|
||||
if let Err(error) = self.edit_buffer(PathBuf::from(args), true) {
|
||||
self.set_message(format!("Open failed: {error}"));
|
||||
}
|
||||
}
|
||||
"buffers" | "bnext" | "bprev" if !args.is_empty() => {
|
||||
self.set_message(format!("The {name} command takes no arguments"));
|
||||
}
|
||||
@@ -1670,6 +1807,7 @@ impl Editor {
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
self.palette = None;
|
||||
self.blur_file_picker();
|
||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||
let (name, args) = command
|
||||
.split_once(char::is_whitespace)
|
||||
@@ -2978,6 +3116,394 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
fn project_editor(directory: &Directory) -> Editor {
|
||||
let mut editor = editor();
|
||||
editor.project_root = directory.0.clone();
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_actions_default_leaders_palette_and_side_configuration() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "abcdef").unwrap();
|
||||
for visual in [false, true] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("abcdef".into())])
|
||||
.unwrap();
|
||||
if visual {
|
||||
type_text(&mut editor, "vl");
|
||||
}
|
||||
let mode = editor.mode();
|
||||
let selections = editor.selections();
|
||||
let carets = editor.carets();
|
||||
for (suffix, presentation) in [
|
||||
("3 o", FilePresentation::Floating),
|
||||
("2 e", FilePresentation::Docked),
|
||||
] {
|
||||
type_text(&mut editor, suffix);
|
||||
let picker = editor.file_picker().unwrap();
|
||||
assert_eq!(picker.presentation, presentation);
|
||||
assert!(picker.focused);
|
||||
assert_eq!(editor.mode(), mode);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.leader_pending(), None);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for (name, binding) in [
|
||||
("core.file_picker", "Space o"),
|
||||
("core.file_explorer", "Space e"),
|
||||
] {
|
||||
let entry = palette_entry(&editor, name);
|
||||
assert!(entry.enabled);
|
||||
assert_eq!(entry.bindings, vec![binding]);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.file_explorer_side(), DockSide::Right);
|
||||
for (name, side) in [
|
||||
("LEFT", DockSide::Left),
|
||||
("rIgHt", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("BOTTOM", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
assert_eq!(editor.file_explorer_side(), side);
|
||||
}
|
||||
assert!(editor.set_file_explorer_side("middle").is_err());
|
||||
assert_eq!(editor.file_explorer_side(), DockSide::Bottom);
|
||||
assert!(
|
||||
editor
|
||||
.bind("insert", "ctrl+o", Some("core.file_picker".into()))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
editor
|
||||
.bind("command", "ctrl+e", Some("core.file_explorer".into()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_focus_literal_input_navigation_and_empty_selection() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "text").unwrap();
|
||||
}
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, " o");
|
||||
editor.handle(Input::Ctrl('K'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 2);
|
||||
editor.handle(Input::Ctrl('N'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
type_text(&mut editor, "界 SPACE [E\u{301}]");
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 1);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.leader_pending(), None);
|
||||
let query = editor.file_picker().unwrap().query.clone();
|
||||
editor.handle(Input::Paste("ignored 5 b n".into()));
|
||||
assert_eq!(editor.file_picker().unwrap().query, query);
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert_eq!(
|
||||
editor.path(),
|
||||
Some(directory.path("a space 界[e\u{301}].txt").as_path())
|
||||
);
|
||||
assert_eq!(editor.context().text, "text");
|
||||
type_text(&mut editor, " o");
|
||||
type_text(&mut editor, "e\u{301}👩💻");
|
||||
editor.handle(Input::Backspace);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "e\u{301}");
|
||||
editor.handle(Input::Backspace);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
||||
editor.handle(Input::Backspace);
|
||||
type_text(&mut editor, "missing 123");
|
||||
let id = editor.buffer_id();
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Escape);
|
||||
let empty = Directory::new();
|
||||
editor.project_root = empty.0.clone();
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("keep.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, " ekeep");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
fs::write(directory.path("keep new.txt"), "new").unwrap();
|
||||
type_text(&mut editor, " o");
|
||||
let picker = editor.file_picker().unwrap();
|
||||
assert_eq!(picker.presentation, FilePresentation::Floating);
|
||||
assert_eq!(picker.query, "keep");
|
||||
assert_eq!(picker.entries().len(), 1);
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "keep");
|
||||
editor.handle(Input::Ctrl('R'));
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 2);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "keep");
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, " e");
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "keep");
|
||||
fs::remove_file(directory.path("keep new.txt")).unwrap();
|
||||
editor.handle(Input::Ctrl('r'));
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 1);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
type_text(&mut editor, " o");
|
||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.path("a space 界.txt");
|
||||
let b = directory.path("b.txt");
|
||||
fs::write(&a, "original").unwrap();
|
||||
fs::write(&b, "other").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.execute(&format!("edit {}", a.display()));
|
||||
editor.apply(vec![Effect::Replace("dirty".into())]).unwrap();
|
||||
type_text(&mut editor, "vl");
|
||||
let selections = editor.selections();
|
||||
let id = editor.buffer_id();
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
fs::write(&a, b"\xff").unwrap();
|
||||
type_text(&mut editor, " ea space 界");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.buffer_count(), 3);
|
||||
assert_eq!(editor.context().text, "dirty");
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "a space 界");
|
||||
editor.execute("bnext");
|
||||
assert!(editor.file_picker().is_some());
|
||||
editor.execute("bdelete");
|
||||
assert!(editor.file_picker().is_some());
|
||||
editor.execute("buffer 1");
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Docked
|
||||
);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "a space 界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_picker_selection_preserves_visual_state_and_insert_group() {
|
||||
let directory = Directory::new();
|
||||
let bad = directory.path("bad.txt");
|
||||
fs::write(&bad, b"\xff").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("abcdef".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "llvhh");
|
||||
let selections = editor.selections();
|
||||
let carets = editor.carets();
|
||||
let undo = editor.active.undo.len();
|
||||
type_text(&mut editor, " obad");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.context().text, "abcdef");
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
assert_eq!(editor.next_buffer_id, 2);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
assert!(
|
||||
editor
|
||||
.file_picker()
|
||||
.unwrap()
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Open failed")
|
||||
);
|
||||
editor.handle(Input::Down);
|
||||
assert!(editor.file_picker().unwrap().error.is_none());
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Backspace);
|
||||
assert!(editor.file_picker().unwrap().error.is_none());
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('i'));
|
||||
type_text(&mut editor, "X");
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
assert!(editor.file_picker().unwrap().error.is_some());
|
||||
fs::remove_file(&bad).unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.file_picker().unwrap().error.is_some());
|
||||
fs::create_dir(&bad).unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, "Y");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "abcdef");
|
||||
assert!(editor.file_picker().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_palette_and_external_commands_are_mutually_exclusive() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
assert!(editor.palette().is_none());
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.palette().is_some());
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, "file picker");
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Floating
|
||||
);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert!(editor.palette().is_some());
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " o");
|
||||
editor.execute("buffers");
|
||||
assert!(editor.file_picker().is_none());
|
||||
type_text(&mut editor, " efile");
|
||||
editor.execute("buffers");
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, " efile");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "2j");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
type_text(&mut editor, "3 b");
|
||||
assert_eq!(editor.leader_pending(), Some("Space b"));
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "i o e123");
|
||||
editor.handle(Input::Paste(" literal".into()));
|
||||
assert!(editor.context().text.contains(" o e123 literal"));
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, ": o e123");
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line(), " o e123");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "/ o e123");
|
||||
assert_eq!(editor.mode(), Mode::Search);
|
||||
assert_eq!(editor.command_line(), " o e123");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " ");
|
||||
assert!(editor.palette().is_some());
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, "iX");
|
||||
editor.open_file_picker(FilePresentation::Floating);
|
||||
editor.handle(Input::Paste("ignored".into()));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
assert_eq!(editor.context().text, "X");
|
||||
type_text(&mut editor, "Y");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "");
|
||||
fs::remove_dir_all(&directory.0).unwrap();
|
||||
type_text(&mut editor, " o");
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert!(editor.message().contains("File scan failed"));
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() {
|
||||
let directory = Directory::new();
|
||||
let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")];
|
||||
fs::write(directory.0.join(&names[0]), "first").unwrap();
|
||||
fs::write(directory.0.join(&names[1]), "second").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
for (name, text) in names.iter().zip(["first", "second"]) {
|
||||
type_text(&mut editor, " osame");
|
||||
let picker = editor.file_picker.as_mut().unwrap();
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
assert_eq!(
|
||||
picker.entries().next().unwrap().label,
|
||||
picker.entries().last().unwrap().label,
|
||||
);
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|entry| !entry.label.chars().any(char::is_control))
|
||||
);
|
||||
let selected = picker
|
||||
.entries()
|
||||
.position(|entry| entry.path == *name)
|
||||
.unwrap();
|
||||
picker.selected = selected;
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.path(), Some(directory.0.join(name).as_path()));
|
||||
assert_eq!(editor.context().text, text);
|
||||
}
|
||||
assert_eq!(editor.buffer_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
use ignore::WalkBuilder;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FilePresentation {
|
||||
Floating,
|
||||
Docked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DockSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
pub struct FileEntry {
|
||||
/// The actual project-relative OS path, never reconstructed from the label.
|
||||
pub path: PathBuf,
|
||||
pub label: String,
|
||||
search_label: String,
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
pub root: PathBuf,
|
||||
pub query: String,
|
||||
pub selected: usize,
|
||||
pub presentation: FilePresentation,
|
||||
pub focused: bool,
|
||||
pub error: Option<String>,
|
||||
files: Vec<FileEntry>,
|
||||
matches: Vec<usize>,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
pub fn scan(root: PathBuf, presentation: FilePresentation) -> io::Result<Self> {
|
||||
if !fs::metadata(&root)?.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotADirectory,
|
||||
"Project root is not a directory",
|
||||
));
|
||||
}
|
||||
// Root failures are fatal; descendant failures leave a visibly incomplete list.
|
||||
fs::read_dir(&root)?;
|
||||
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
|
||||
let mut walk = WalkBuilder::new(&root);
|
||||
walk.require_git(false)
|
||||
.parents(false)
|
||||
.hidden(false)
|
||||
.git_global(false)
|
||||
.follow_links(false)
|
||||
.filter_entry(|entry| {
|
||||
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
|
||||
});
|
||||
let mut files = Vec::new();
|
||||
let mut warning = None;
|
||||
for entry in walk.build() {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
if error.depth() == Some(0) {
|
||||
return Err(io::Error::other(error));
|
||||
}
|
||||
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(error) = entry.error() {
|
||||
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
|
||||
}
|
||||
// Symlinks (including file symlinks) are intentionally excluded.
|
||||
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry
|
||||
.path()
|
||||
.strip_prefix(&root)
|
||||
.expect("walk stays within root")
|
||||
.to_owned();
|
||||
let label: String = path
|
||||
.to_string_lossy()
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch })
|
||||
.collect();
|
||||
let search_label = label.to_lowercase();
|
||||
files.push(FileEntry {
|
||||
path,
|
||||
label,
|
||||
search_label,
|
||||
});
|
||||
}
|
||||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let matches = (0..files.len()).collect();
|
||||
Ok(Self {
|
||||
root,
|
||||
query: String::new(),
|
||||
selected: 0,
|
||||
presentation,
|
||||
focused: true,
|
||||
error: warning.clone(),
|
||||
files,
|
||||
matches,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = &FileEntry> + '_ {
|
||||
self.matches.iter().map(|&index| &self.files[index])
|
||||
}
|
||||
|
||||
pub(crate) fn refilter(&mut self) {
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
self.matches.clear();
|
||||
self.matches
|
||||
.extend(self.files.iter().enumerate().filter_map(|(index, entry)| {
|
||||
tokens
|
||||
.iter()
|
||||
.all(|token| entry.search_label.contains(token))
|
||||
.then_some(index)
|
||||
}));
|
||||
self.selected = 0;
|
||||
self.clear_error();
|
||||
}
|
||||
|
||||
pub(crate) fn backspace(&mut self) {
|
||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
||||
self.query.truncate(byte);
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||
let count = self.matches.len();
|
||||
self.selected = if count == 0 {
|
||||
0
|
||||
} else if forward {
|
||||
(self.selected + 1) % count
|
||||
} else if self.selected == 0 {
|
||||
count - 1
|
||||
} else {
|
||||
self.selected - 1
|
||||
};
|
||||
self.clear_error();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_error(&mut self) {
|
||||
self.error = self.warning.clone();
|
||||
}
|
||||
|
||||
pub(crate) fn refresh(&mut self) {
|
||||
match Self::scan(self.root.clone(), self.presentation) {
|
||||
Ok(mut replacement) => {
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.refilter();
|
||||
*self = replacement;
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn scan(&self) -> FilePicker {
|
||||
FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
||||
picker.entries().map(|entry| entry.path.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "*.log\nignored/\n");
|
||||
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
||||
for name in [
|
||||
"gone.log",
|
||||
"nested/gone.log",
|
||||
"nested/keep.log",
|
||||
"nested/local.txt",
|
||||
"nested/ok.txt",
|
||||
"ignored/hidden.txt",
|
||||
".hidden",
|
||||
".git/objects/secret",
|
||||
"nested/.git/secret",
|
||||
".jj/secret",
|
||||
"nested/.jj/secret",
|
||||
] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let picker = directory.scan();
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[
|
||||
".gitignore",
|
||||
".hidden",
|
||||
"nested/.gitignore",
|
||||
"nested/keep.log",
|
||||
"nested/ok.txt"
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
);
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
assert_eq!(picker.selected, 2);
|
||||
picker.navigate(true);
|
||||
assert_eq!(picker.selected, 0);
|
||||
picker.query = "界 SPACE [E\u{301}]".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||
picker.query = "e\u{301}👩💻".into();
|
||||
picker.backspace();
|
||||
assert_eq!(picker.query, "e\u{301}");
|
||||
picker.backspace();
|
||||
assert_eq!(picker.query, "");
|
||||
picker.backspace();
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
picker.query = "no match".into();
|
||||
picker.refilter();
|
||||
for forward in [true, false] {
|
||||
picker.navigate(forward);
|
||||
assert_eq!(picker.selected, 0);
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
picker.backspace();
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
directory.put("new.txt", "text");
|
||||
picker.query = "new".into();
|
||||
picker.refresh();
|
||||
assert_eq!(picker.query, "new");
|
||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||
fs::remove_dir_all(&directory.0).unwrap();
|
||||
picker.refresh();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
|
||||
assert!(FilePicker::scan(directory.0.clone(), FilePresentation::Docked).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ignore_warning_survives_query_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "[z-a]\n");
|
||||
directory.put("ok.txt", "text");
|
||||
let mut picker = directory.scan();
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
picker.query = "ok".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
||||
picker.navigate(true);
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
directory.put(".gitignore", "");
|
||||
picker.refresh();
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "*.txt\n");
|
||||
directory.put("project/visible.txt", "text");
|
||||
let root = directory.0.join("project");
|
||||
let picker = FilePicker::scan(root, FilePresentation::Floating).unwrap();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
||||
assert!(
|
||||
FilePicker::scan(
|
||||
directory.0.join("project/visible.txt"),
|
||||
FilePresentation::Docked
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let directory = Directory::new();
|
||||
directory.put("real.txt", "text");
|
||||
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
||||
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
||||
let raw = PathBuf::from("raw界\n.txt");
|
||||
fs::write(directory.0.join(&raw), "text").unwrap();
|
||||
let picker = directory.scan();
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
let entry = picker.entries().find(|entry| entry.path == raw).unwrap();
|
||||
assert!(!entry.label.chars().any(char::is_control));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(&entry.path)).unwrap(),
|
||||
"text"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
pub mod tui;
|
||||
|
||||
@@ -10,6 +10,8 @@ Default keys (configurable with config.bind):
|
||||
All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept, Esc cancel
|
||||
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
|
||||
Files: Space o floating picker; Space e docked explorer (right by default)
|
||||
type to filter, Up/Down select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
|
||||
+8
-3
@@ -18,6 +18,7 @@ pub struct Config {
|
||||
pub plugins: Vec<PathBuf>,
|
||||
pub bindings: Vec<Binding>,
|
||||
pub leader: String,
|
||||
pub file_explorer_side: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -26,6 +27,7 @@ impl Default for Config {
|
||||
plugins: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
leader: "space".into(),
|
||||
file_explorer_side: "right".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +43,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
plugins,
|
||||
bindings,
|
||||
leader,
|
||||
file_explorer_side,
|
||||
..
|
||||
} => {
|
||||
let mut config = Config::default();
|
||||
@@ -53,6 +56,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
}
|
||||
config.bindings = bindings;
|
||||
config.leader = leader;
|
||||
config.file_explorer_side = file_explorer_side;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -198,6 +202,7 @@ enum Response {
|
||||
plugins: Vec<String>,
|
||||
bindings: Vec<Binding>,
|
||||
leader: String,
|
||||
file_explorer_side: String,
|
||||
},
|
||||
#[serde(rename = "result")]
|
||||
Result {
|
||||
@@ -593,9 +598,9 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[]}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
|
||||
+10
-1
@@ -30,6 +30,7 @@ module Vamp
|
||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
||||
select copy cut change paste_before paste_after
|
||||
select_lines clone_above clone_below select_next skip_occurrence select_all
|
||||
file_picker file_explorer
|
||||
].each do |name|
|
||||
action = Action.new("core.#{name}".freeze).freeze
|
||||
define_singleton_method(name) { action }
|
||||
@@ -51,6 +52,7 @@ module Vamp
|
||||
@plugins = []
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
end
|
||||
|
||||
def plugin(path)
|
||||
@@ -66,6 +68,12 @@ module Vamp
|
||||
@leader = key
|
||||
end
|
||||
|
||||
def file_explorer_side=(side)
|
||||
side = Vamp.string!(side, "file explorer side")
|
||||
raise ArgumentError, "file explorer side must not be empty" if side.empty?
|
||||
@file_explorer_side = side
|
||||
end
|
||||
|
||||
def core
|
||||
Core
|
||||
end
|
||||
@@ -97,7 +105,8 @@ module Vamp
|
||||
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||
"bindings" => @bindings, "leader" => @leader }
|
||||
"bindings" => @bindings, "leader" => @leader,
|
||||
"file_explorer_side" => @file_explorer_side }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ impl EditorSession {
|
||||
if let Err(error) = editor.set_leader(&config.leader) {
|
||||
notices.push(format!("leader {:?}: {error}", config.leader));
|
||||
}
|
||||
if let Err(error) = editor.set_file_explorer_side(&config.file_explorer_side) {
|
||||
notices.push(format!(
|
||||
"file explorer side {:?}: {error}",
|
||||
config.file_explorer_side
|
||||
));
|
||||
}
|
||||
let mut plugins = BTreeMap::new();
|
||||
for path in config.plugins {
|
||||
match RubyPlugin::start(&path) {
|
||||
@@ -158,6 +164,80 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_picker_presentations_preserve_editing_and_docked_focus() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "alpha");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.file_explorer_side = "left"
|
||||
config.bind "ctrl+o", config.core.file_picker
|
||||
config.bind "ctrl+e", config.core.file_explorer
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().file_explorer_side(),
|
||||
crate::files::DockSide::Left
|
||||
);
|
||||
session.handle(Input::Ctrl('e'));
|
||||
assert_eq!(
|
||||
session.editor().file_picker().unwrap().presentation,
|
||||
crate::files::FilePresentation::Docked
|
||||
);
|
||||
for ch in "dir e\u{301}".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Backspace);
|
||||
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
|
||||
session.handle(Input::Escape);
|
||||
assert!(!session.editor().file_picker().unwrap().focused);
|
||||
session.handle(Input::Char('i'));
|
||||
for ch in " a b ".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), &[" a b alpha"]);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(
|
||||
session.editor().file_picker().unwrap().presentation,
|
||||
crate::files::FilePresentation::Floating
|
||||
);
|
||||
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
|
||||
session.handle(Input::Escape);
|
||||
assert!(session.editor().file_picker().is_none());
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_explorer_side_preserves_default_and_valid_bindings() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "abc");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.file_explorer_side = "diagonal"
|
||||
config.bind "n", config.core.right
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().file_explorer_side(),
|
||||
crate::files::DockSide::Right
|
||||
);
|
||||
session.handle(Input::Char('n'));
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 0, column: 1 }
|
||||
);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), &["a!bc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_uses_space_leader_for_palette_and_write() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -962,6 +1042,18 @@ end
|
||||
("config.leader = 7", "leader key must be a String"),
|
||||
("config.leader = nil", "leader key must be a String"),
|
||||
(r#"config.leader = """#, "leader key must not be empty"),
|
||||
(
|
||||
"config.file_explorer_side = 7",
|
||||
"file explorer side must be a String",
|
||||
),
|
||||
(
|
||||
"config.file_explorer_side = nil",
|
||||
"file explorer side must be a String",
|
||||
),
|
||||
(
|
||||
r#"config.file_explorer_side = """#,
|
||||
"file explorer side must not be empty",
|
||||
),
|
||||
(r#"config.bind "n", "core.insert""#, "TypeError"),
|
||||
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
|
||||
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
|
||||
|
||||
+671
-7
@@ -18,7 +18,7 @@ use crossterm::{
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout},
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
@@ -28,6 +28,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
@@ -219,12 +220,21 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
return;
|
||||
}
|
||||
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
|
||||
let [viewport, status_area, prompt_area] = Layout::vertical([
|
||||
let dock_focused = editor
|
||||
.file_picker()
|
||||
.is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked);
|
||||
let [workspace, status_area, prompt_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(area.height >= 3)),
|
||||
Constraint::Length(u16::from(area.height >= 2 || prompting)),
|
||||
Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))),
|
||||
])
|
||||
.areas(area);
|
||||
let (viewport, dock) = match editor.file_picker() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => {
|
||||
dock_layout(workspace, editor.file_explorer_side(), picker.focused)
|
||||
}
|
||||
_ => (workspace, None),
|
||||
};
|
||||
let digits = editor.lines().len().ilog10() as usize + 1;
|
||||
let gutter_width = (digits + 1) as u16;
|
||||
let [gutter, viewport] = Layout::horizontal([
|
||||
@@ -239,7 +249,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let height = usize::from(viewport.height);
|
||||
let width = usize::from(viewport.width);
|
||||
let cursor = editor.cursor();
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
if cursor.row < *top {
|
||||
*top = cursor.row;
|
||||
} else if cursor.row >= top.saturating_add(height) {
|
||||
@@ -372,7 +382,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
status_area,
|
||||
);
|
||||
if prompting && prompt_area.height > 0 {
|
||||
if prompting && prompt_area.height > 0 && prompt_area.width > 0 {
|
||||
let prefix = if editor.mode() == Mode::Search {
|
||||
'/'
|
||||
} else {
|
||||
@@ -381,7 +391,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let command = format!("{prefix}{}", editor.command_line());
|
||||
let command_width = display_column(&command, usize::MAX);
|
||||
let prompt_width = usize::from(prompt_area.width);
|
||||
let offset = command_width.saturating_sub(prompt_width - 1);
|
||||
let offset = command_width.saturating_sub(prompt_width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&command, offset, prompt_width)),
|
||||
prompt_area,
|
||||
@@ -392,7 +402,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
let column = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
frame.set_cursor_position((
|
||||
viewport.x + (column - *left) as u16,
|
||||
@@ -400,11 +410,191 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
if picker.presentation == FilePresentation::Floating {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(84)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(18)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel);
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel);
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
}
|
||||
}
|
||||
|
||||
fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option<Rect>) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return (area, None);
|
||||
}
|
||||
let horizontal = matches!(side, DockSide::Left | DockSide::Right);
|
||||
let available = if horizontal { area.width } else { area.height };
|
||||
if !focused && available < if horizontal { 24 } else { 8 } {
|
||||
return (area, None);
|
||||
}
|
||||
let extent = if available < if horizontal { 20 } else { 6 } {
|
||||
available
|
||||
} else if horizontal {
|
||||
(available / 3).clamp(12, 38).min(available - 8)
|
||||
} else {
|
||||
(available / 3).clamp(4, 12).min(available - 2)
|
||||
};
|
||||
let leading = matches!(side, DockSide::Left | DockSide::Top);
|
||||
let constraints = if leading {
|
||||
[Constraint::Length(extent), Constraint::Fill(1)]
|
||||
} else {
|
||||
[Constraint::Fill(1), Constraint::Length(extent)]
|
||||
};
|
||||
let [first, second] = if horizontal {
|
||||
Layout::horizontal(constraints).areas(area)
|
||||
} else {
|
||||
Layout::vertical(constraints).areas(area)
|
||||
};
|
||||
if leading {
|
||||
(second, Some(first))
|
||||
} else {
|
||||
(first, Some(second))
|
||||
}
|
||||
}
|
||||
|
||||
fn display_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
}
|
||||
let color = if picker.focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
let title = match picker.presentation {
|
||||
FilePresentation::Floating => " Files ",
|
||||
FilePresentation::Docked => " Explorer ",
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(color))
|
||||
.title(title);
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let show_root = content.height >= 4;
|
||||
let show_error = picker.error.is_some() && content.height >= 2;
|
||||
let show_help = content.height >= 5 + u16::from(show_error);
|
||||
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(show_root)),
|
||||
Constraint::Length(u16::from(show_error)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(show_help)),
|
||||
])
|
||||
.areas(content);
|
||||
let query = format!("> {}", display_text(&picker.query));
|
||||
let query_width = display_column(&query, usize::MAX);
|
||||
let width = usize::from(query_area.width);
|
||||
let offset = query_width.saturating_sub(width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
|
||||
if picker.focused {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::DarkGray
|
||||
},
|
||||
)),
|
||||
query_area,
|
||||
);
|
||||
if picker.focused && query_area.width > 0 && query_area.height > 0 {
|
||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
||||
}
|
||||
if show_root {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(&picker.root.to_string_lossy()))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
root_area,
|
||||
);
|
||||
}
|
||||
if show_error {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(picker.error.as_deref().unwrap()))
|
||||
.style(Style::default().fg(Color::Red)),
|
||||
error_area,
|
||||
);
|
||||
}
|
||||
let entries = picker.entries();
|
||||
if entries.len() == 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if picker.query.is_empty() {
|
||||
"No project files"
|
||||
} else {
|
||||
"No matching files"
|
||||
}),
|
||||
results_area,
|
||||
);
|
||||
} else if results_area.height > 0 {
|
||||
let count = usize::from(results_area.height);
|
||||
let first = picker.selected.saturating_sub(count - 1);
|
||||
let lines: Vec<Line> = entries
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(count)
|
||||
.map(|(index, entry)| {
|
||||
let selected = index == picker.selected;
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.bg(if picker.focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::styled(
|
||||
format!("{} {}", if selected { ">" } else { " " }, entry.label),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
let help = if picker.focused {
|
||||
match picker.presentation {
|
||||
FilePresentation::Floating => "↑↓ Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "↑↓ Enter · Esc/Tab edit · ^W · ^R",
|
||||
}
|
||||
} else {
|
||||
"Editor focus · invoke explorer to search"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
@@ -548,6 +738,480 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
|
||||
let directory = Directory::new();
|
||||
for index in 0..18 {
|
||||
directory.file(&format!("file-{index:02}"), "text");
|
||||
}
|
||||
let special = directory.file("界e\u{301} space\nname", "text");
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|entry| directory.0.join(&entry.path) == special)
|
||||
);
|
||||
let count = picker.entries().len();
|
||||
picker.selected = count - 1;
|
||||
let label = picker.entries().last().unwrap().label.clone();
|
||||
assert_eq!(label, "界e\u{301} space�name");
|
||||
picker.query = "long\t界e\u{301}z".into();
|
||||
let area = Rect::new(3, 2, 24, 9);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(40, 15),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("file-00"), "{text}");
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�");
|
||||
assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界");
|
||||
assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}");
|
||||
let selected_y = (area.y + 2..area.bottom() - 1)
|
||||
.find(|&y| buffer[(area.x + 1, y)].symbol() == ">")
|
||||
.unwrap();
|
||||
assert_eq!(buffer[(area.x + 3, selected_y)].symbol(), "界");
|
||||
assert_eq!(buffer[(area.x + 5, selected_y)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue);
|
||||
assert!(
|
||||
buffer[(area.x + 1, selected_y)]
|
||||
.modifier
|
||||
.contains(Modifier::BOLD)
|
||||
);
|
||||
assert_eq!(cursor.y, area.y + 1);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
let area = Rect::new(0, 0, 100, 12);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Explorer"), "{text}");
|
||||
assert!(
|
||||
text.contains(&directory.0.to_string_lossy().to_string()),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("No project files"), "{text}");
|
||||
assert!(text.contains("Esc/Tab"), "{text}");
|
||||
picker.query = "missing".into();
|
||||
picker.error = Some("Unreadable\npath\u{1b}".into());
|
||||
picker.focused = false;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.set_cursor_position((99, 11));
|
||||
draw_file_list(frame, &picker, area);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("No matching files"), "{text}");
|
||||
assert!(text.contains("Unreadable�path�"), "{text}");
|
||||
assert!(text.contains("Editor focus"), "{text}");
|
||||
assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red);
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(99, 11)
|
||||
);
|
||||
picker.presentation = FilePresentation::Floating;
|
||||
picker.focused = true;
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
picker.query = "long界e\u{301}z".into();
|
||||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(24, 12),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
let bordered = width >= 4 && height >= 4;
|
||||
let query_x = area.x + u16::from(bordered);
|
||||
let query_y = area.y + u16::from(bordered);
|
||||
let query_width = width - 2 * u16::from(bordered);
|
||||
assert_eq!(cursor.x, query_x + query_width - 1);
|
||||
assert_eq!(cursor.y, query_y);
|
||||
let query = format!("> {}", picker.query);
|
||||
let offset =
|
||||
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
|
||||
let mut expected =
|
||||
ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1));
|
||||
use ratatui::widgets::Widget;
|
||||
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
|
||||
.render(expected.area, &mut expected);
|
||||
for x in query_x..query_x + query_width {
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(x, query_y)].symbol(),
|
||||
expected[(x, query_y)].symbol()
|
||||
);
|
||||
}
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
|
||||
for side in [
|
||||
DockSide::Left,
|
||||
DockSide::Right,
|
||||
DockSide::Top,
|
||||
DockSide::Bottom,
|
||||
] {
|
||||
for focused in [false, true] {
|
||||
let (editor, panel) = dock_layout(area, side, focused);
|
||||
for rect in [Some(editor), panel].into_iter().flatten() {
|
||||
assert!(rect.x >= area.x && rect.y >= area.y);
|
||||
assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom());
|
||||
}
|
||||
if focused {
|
||||
assert!(panel.is_some());
|
||||
} else {
|
||||
assert!(panel.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, frame.area()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||||
.1
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["word ".repeat(40); 20].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('a'));
|
||||
assert!(editor.selection_count() > 1);
|
||||
let area = Rect::new(3, 2, 90, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, true);
|
||||
let panel = panel.unwrap();
|
||||
assert_eq!(editor_area.area() + panel.area(), workspace.area());
|
||||
assert_eq!(editor_area.intersection(panel).area(), 0);
|
||||
match side {
|
||||
DockSide::Left => assert_eq!(panel.right(), editor_area.x),
|
||||
DockSide::Right => assert_eq!(editor_area.right(), panel.x),
|
||||
DockSide::Top => assert_eq!(panel.bottom(), editor_area.y),
|
||||
DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y),
|
||||
}
|
||||
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
|
||||
let buffer = terminal.backend().buffer();
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert!(
|
||||
!matches!(
|
||||
buffer[(x, y)].bg,
|
||||
Color::Blue | Color::Magenta | Color::Yellow
|
||||
),
|
||||
"{side:?}: {x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w");
|
||||
assert!(matches!(
|
||||
buffer[(editor_area.x + 3, editor_area.y)].bg,
|
||||
Color::Blue | Color::Magenta
|
||||
));
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.x + 3 {
|
||||
assert_eq!(buffer[(x, y)].bg, Color::Reset);
|
||||
}
|
||||
}
|
||||
for x in area.x..area.right() {
|
||||
assert_eq!(buffer[(x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["x".repeat(120); 30].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
let area = Rect::new(3, 2, 110, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(120, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let before = (top, left);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
||||
assert_eq!((top, left), before);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(panel.x + 3, panel.y + 1)
|
||||
);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x");
|
||||
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n"));
|
||||
let b = directory.file("b", "other");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
for _ in 0..20 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let before = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Explorer"));
|
||||
editor.handle(Input::Escape);
|
||||
let docked = editor.file_picker().unwrap();
|
||||
assert!(!docked.focused);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let dock_offsets = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Commands"));
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_some());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Floating
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
assert!(screen_text(&terminal).contains("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"0123456789界e\u{301}z\nsecond".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
editor.handle(Input::Char('界'));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.search_match().is_some());
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
for (width, height) in [
|
||||
(90, 26),
|
||||
(12, 6),
|
||||
(8, 2),
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(0, 1),
|
||||
(1, 0),
|
||||
(90, 26),
|
||||
] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
terminal.resize(area).unwrap();
|
||||
let area = terminal.get_frame().area();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (_, panel) = dock_layout(workspace, side, true);
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(
|
||||
panel.unwrap().contains(cursor),
|
||||
"{side:?} {area:?} {panel:?}: {cursor:?}"
|
||||
);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}");
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, false);
|
||||
assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
if width == 90 {
|
||||
let mut yellow = 0;
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.right() {
|
||||
yellow += usize::from(buffer[(x, y)].bg == Color::Yellow);
|
||||
}
|
||||
}
|
||||
assert!(yellow > 0);
|
||||
}
|
||||
if let Some(panel) = panel {
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert_ne!(buffer[(x, y)].bg, Color::Yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user