feat(editor): add v0.5 file palette (Cmd-P) with fuzzy match

Cmd-P opens a modal transient palette over the writing column: a bare
fuzzy-search input, the ranked file list, and the selected row in reverse
video. A pure host-testable fuzzy_score (subsequence + word-boundary and
consecutive-run bonuses) ranks results; an in-core MRU floats recently
opened files first and is shared with :e (both route through open_path).
The host feeds the file list once at boot (enumerate_files over /sd/repo
and /sd/local). Ctrl-n/Ctrl-p navigate the list; Enter opens via the same
park/evict path as :e; Esc closes.

Ctrl-n/Ctrl-p also become down/up line motions in Normal and View (vim
CTRL-N/CTRL-P, count-aware), which is why the palette opener is Cmd-P
alone. No `>` prefix on the file input — `>` is reserved for the command
palette (slice 4).

112 editor + 28 keymap tests; the no-git firmware binary builds clean.
This commit is contained in:
Julien Calixte
2026-07-12 00:11:33 +02:00
parent 2215da939d
commit e967773bd6
5 changed files with 648 additions and 18 deletions

View File

@@ -10,7 +10,7 @@ use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use display::Frame;
use editor::{Editor, Effect, Mode, Scope, CH};
use editor::{Editor, Effect, Mode, Scope, CH, LOCAL_DIR, REPO_DIR};
use firmware::epd::{self, Epd};
use firmware::persistence::{Storage, NOTES};
@@ -112,6 +112,9 @@ fn main() -> anyhow::Result<()> {
.and_then(|s| s.to_str())
.unwrap_or("notes");
ed.set_notice(format!("loaded {name}"));
// Feed the file palette (Ctrl-P). Enumerated once at boot — the v0.5 slices
// that create/delete files (`:enew`, delete) will re-feed it then.
ed.set_file_list(enumerate_files());
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
@@ -418,6 +421,38 @@ fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
}
}
/// Enumerate the palette's openable files: the top-level regular files in
/// `/sd/repo` and `/sd/local`, as absolute paths. Skips dotfiles (so `.git`,
/// `.typoena.toml`, and the like never show) and anything that isn't a plain
/// file. Best-effort: an unreadable directory (e.g. no `/sd/local` yet)
/// contributes nothing rather than failing. The editor sorts and dedupes.
fn enumerate_files() -> Vec<String> {
let mut out = Vec::new();
for dir in [REPO_DIR, LOCAL_DIR] {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
// Stat rather than trust d_type — FatFS's dirent type can read back
// as unknown; a plain metadata call is reliable here.
if !std::fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false) {
continue;
}
if let Some(p) = path.to_str() {
out.push(p.to_string());
}
}
}
out
}
/// A file's display name — its basename without extension (`/sd/repo/notes.md`
/// → `notes`), for the snackbar. Falls back to the raw path if it has no stem.
fn file_stem(path: &str) -> &str {