Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
79fad4689c docs(palette): amend v0.5 with the recursive walk and search threshold 2026-07-13 00:55:34 +02:00
Julien Calixte
98fc817b3f feat(palette): show recents only until the query reaches two chars
With the file list now a recursive walk of the whole card, an unranked
short query pages through too much. Below PALETTE_MIN_QUERY (2) the
result list is the MRU only, keeping Cmd-P + Enter quick-switch one
keystroke away; two chars reveal the full fuzzy-ranked list. Commands
(>) and snippets ($) are short curated lists and keep matching from
the first char.
2026-07-13 00:55:25 +02:00
Julien Calixte
d306caacf7 feat(palette): walk the card recursively for the file list
A nested repo (jcalixte/notes) showed only its top-level files in the
palette. Dot entries are skipped at every level so .git is never
descended; each directory is read fully before recursing so a single
FatFS dir handle is open at a time; depth capped at 8. The boot walk
logs its file count and duration to keep the FAT dir-IO cost visible.
2026-07-13 00:55:16 +02:00
3 changed files with 139 additions and 31 deletions

View File

@@ -149,6 +149,24 @@ shown). On-device check: reflash → open a note (trailing empty line visible)
newline for the same reason; the guarded save leaves the prefs file with exactly
one.
**Amendment 2026-07-13 — recursive enumeration + a 2-char search threshold.**
Loading a real repo (`jcalixte/notes`) exposed that `enumerate_files` listed
only the **top-level** files of `/sd/repo` and `/sd/local` — a nested notes tree
showed a single file in the palette (subpaths always *opened* fine via
`:e repo/sub/x.md`; only the listing was flat). The enumeration is now a
recursive walk: dot entries are skipped at every level (so `.git` is never
descended into), each directory is read fully before recursing (one FatFS dir
handle open at a time — the `remove_dir_recursive` pattern, kind to the
FD-bounded mount), depth is capped at 8, and the boot-time walk logs its file
count and duration (`file walk: N files in Xms`) so the FAT dir-IO cost on a
big repo is measurable, not assumed. With the list now card-sized, the palette
gained a **search threshold**: below 2 typed chars the result list is the
**recents (MRU) only** — quick-switch (`Cmd-P`, `Enter`) stays one keystroke
away — and the full fuzzy-ranked list appears from 2 chars on
(`PALETTE_MIN_QUERY`). A fresh boot with no opens yet shows `(type to search)`.
`>` commands and `$` snippets are short curated lists; the threshold does not
apply to them.
- [x] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/`**landed and CONFIRMED ON DEVICE 2026-07-12** (Spike 11: no
ghosting on the transient panel); scope shows as the inline

View File

@@ -743,8 +743,9 @@ pub struct Editor {
/// key batch. See [`Effect`].
requests: Vec<Effect>,
/// Every openable file, as absolute paths, fed by the host at boot via
/// [`set_file_list`](Self::set_file_list) (an enumeration of `/sd/repo` and
/// `/sd/local`). The palette fuzzy-filters this; empty until the host feeds it.
/// [`set_file_list`](Self::set_file_list) (a recursive walk of `/sd/repo`
/// and `/sd/local`). The palette fuzzy-filters this once the query reaches
/// [`PALETTE_MIN_QUERY`] chars; empty until the host feeds it.
files: Vec<String>,
/// Recently-opened files, most-recent-first (an MRU), deduped and bounded to
/// [`MRU_MAX`]. Every `:e`/palette open pushes to the front
@@ -798,12 +799,20 @@ struct Buffer {
/// evicted; it is saved first if dirty, so an evicted buffer is never lost.
const MAX_RESIDENT: usize = 3;
/// Recent-files (MRU) list length — how many opens the palette remembers to
/// float to the top on an empty query. Far more than [`MAX_RESIDENT`] (recency
/// Recent-files (MRU) list length — how many opens the palette remembers; they
/// are the whole result list below [`PALETTE_MIN_QUERY`] chars and float to the
/// top above it. Far more than [`MAX_RESIDENT`] (recency
/// outlives residency: a file evicted from memory is still recently *used*), but
/// bounded so the list can't grow without limit over a long session.
const MRU_MAX: usize = 16;
/// Query length (chars) at which the file palette searches the full file list.
/// Shorter queries show only the recents ([`MRU_MAX`]) — the list is a
/// recursive walk of the card, and one char can't rank hundreds of paths
/// usefully. `>` commands and `$` snippets are short curated lists, so the
/// threshold does not apply to them.
const PALETTE_MIN_QUERY: usize = 2;
/// Maximum undo depth (change-groups). A full-buffer snapshot per group means
/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the
/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups
@@ -1924,6 +1933,11 @@ impl Editor {
/// Base order is MRU-first (recents in use order, then the rest as sorted). A
/// non-empty query keeps only fuzzy matches and stable-sorts them by score, so
/// equal scores keep their MRU/base position. See [`fuzzy_score`].
///
/// Below [`PALETTE_MIN_QUERY`] chars the candidate set is the recents only:
/// the file list is a recursive walk of the whole card, too long to page
/// through unranked, but the MRU keeps quick-switch (`Cmd-P`, `Enter`) one
/// keystroke away. Two typed chars reveal the full list.
fn palette_matches(&self) -> Vec<usize> {
let mut order: Vec<usize> = Vec::with_capacity(self.files.len());
for r in &self.recent {
@@ -1931,9 +1945,11 @@ impl Editor {
order.push(i);
}
}
for i in 0..self.files.len() {
if !order.contains(&i) {
order.push(i);
if self.palette_query.chars().count() >= PALETTE_MIN_QUERY {
for i in 0..self.files.len() {
if !order.contains(&i) {
order.push(i);
}
}
}
if self.palette_query.is_empty() {
@@ -3562,6 +3578,10 @@ impl Editor {
"(no command)"
} else if self.files.is_empty() {
"(no files on card)"
} else if self.palette_query.chars().count() < PALETTE_MIN_QUERY {
// No recents yet and the query is below the search threshold —
// the full list needs 2+ chars.
"(type to search)"
} else {
"(no match)"
};
@@ -5124,6 +5144,9 @@ mod tests {
e.take_effects();
assert!(!e.files.contains(&"/sd/repo/notes.md".to_string()));
e.handle(Key::Palette);
for c in "md".chars() {
e.handle(Key::Char(c)); // reach the search threshold
}
assert_eq!(palette_labels(&e), vec!["repo/todo.md"]); // only the survivor
}
@@ -5272,6 +5295,9 @@ mod tests {
fn half_page_keys_move_the_selection_clamped() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
e.handle(Key::Palette);
for ch in "md".chars() {
e.handle(Key::Char(ch)); // reach the search threshold: all three match
}
assert_eq!(e.palette_sel, 0);
e.handle(Key::HalfPageDown);
assert_eq!(e.palette_sel, 1);
@@ -5286,6 +5312,9 @@ mod tests {
fn ctrl_n_p_navigate_the_palette() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]);
e.handle(Key::Palette);
for ch in "md".chars() {
e.handle(Key::Char(ch)); // reach the search threshold: all three match
}
e.handle(Key::Down); // Ctrl-n
assert_eq!(e.palette_sel, 1);
e.handle(Key::Down);
@@ -5337,6 +5366,9 @@ mod tests {
fn editing_the_query_resets_the_selection_to_the_top() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md"]);
e.handle(Key::Palette);
for ch in "md".chars() {
e.handle(Key::Char(ch)); // reach the search threshold: both match
}
e.handle(Key::HalfPageDown);
assert_eq!(e.palette_sel, 1);
e.handle(Key::Char('a')); // a query edit resets the selection
@@ -5352,17 +5384,46 @@ mod tests {
}
#[test]
fn empty_query_orders_recents_first_then_sorted() {
fn short_query_lists_recents_only() {
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
// No opens yet: pure sorted order.
assert_eq!(palette_labels(&e), vec!["repo/a.md", "repo/b.md", "repo/c.md"]);
// Open c.md through the palette; it should float to the front next time.
// No opens yet: below the search threshold there is nothing to show.
assert!(palette_labels(&e).is_empty());
// Open c.md through the palette; it becomes the recents-only result.
e.handle(Key::Palette);
for ch in "c.md".chars() {
e.handle(Key::Char(ch));
}
e.handle(Key::Enter);
e.take_effects(); // drop the queued Load; we only care about the MRU
assert_eq!(palette_labels(&e), vec!["repo/c.md"]);
}
#[test]
fn two_char_query_reveals_the_full_file_list() {
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
e.handle(Key::Palette);
e.handle(Key::Char('m')); // one char: still recents-only (none yet)
assert!(e.palette_matches().is_empty());
e.handle(Key::Char('d')); // "md": the full list, fuzzy-ranked
assert_eq!(palette_labels(&e), vec!["repo/a.md", "repo/b.md", "repo/c.md"]);
}
#[test]
fn recents_float_above_the_full_list_on_a_matching_query() {
let mut e = palette_editor(&["/sd/repo/b.md", "/sd/repo/a.md", "/sd/repo/c.md"]);
// Open c.md so it is the MRU head.
e.handle(Key::Palette);
for ch in "c.md".chars() {
e.handle(Key::Char(ch));
}
e.handle(Key::Enter);
e.take_effects();
// "md" scores the three labels equally; the stable sort keeps the
// recently-opened c.md in front of the sorted rest.
e.handle(Key::Palette);
for ch in "md".chars() {
e.handle(Key::Char(ch));
}
assert_eq!(palette_labels(&e), vec!["repo/c.md", "repo/a.md", "repo/b.md"]);
}
@@ -5370,6 +5431,10 @@ mod tests {
fn draw_in_palette_mode_does_not_panic() {
let mut e = palette_editor(&["/sd/repo/a.md", "/sd/local/j.md"]);
e.handle(Key::Palette);
let _ = e.draw(true); // empty query, no recents: "(type to search)"
e.handle(Key::Char('j')); // one char, still below the threshold
let _ = e.draw(true);
e.handle(Key::Char('m')); // at the threshold: the ranked list
let _ = e.draw(true);
// Empty file list: the "(no files on card)" path must also be safe.
let mut empty = Editor::new();

View File

@@ -551,36 +551,61 @@ fn delete_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.
/// Enumerate the palette's openable files: the regular files under `/sd/repo`
/// and `/sd/local`, recursively, as absolute paths. Skips dot entries at every
/// level (so `.git` and its thousands of object files, `.typoena.toml`, and the
/// like never show or get walked). Best-effort: an unreadable directory (e.g.
/// no `/sd/local` yet) contributes nothing rather than failing. The editor
/// sorts and dedupes. Runs once at boot, so the walk time is logged — on a big
/// repo the FAT directory IO is the cost to watch.
fn enumerate_files() -> Vec<String> {
let start = std::time::Instant::now();
let mut out = Vec::new();
for dir in [REPO_DIR, LOCAL_DIR] {
let Ok(entries) = std::fs::read_dir(dir) else {
walk_files(std::path::Path::new(dir), 0, &mut out);
}
log::info!("file walk: {} files in {}ms", out.len(), start.elapsed().as_millis());
out
}
/// Depth bound for [`walk_files`] — belt-and-braces against pathological
/// nesting on a hand-edited card; notes trees are a couple of levels deep.
const WALK_MAX_DEPTH: usize = 8;
/// Recursive helper for [`enumerate_files`]: push `dir`'s files onto `out`,
/// then descend into its subdirectories. Reads each directory fully before
/// recursing (the `remove_dir_recursive` pattern in `git_sync`), so only one
/// FatFS directory handle is open at a time regardless of depth — relevant on
/// the FD-bounded SD mount.
fn walk_files(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) {
if depth > WALK_MAX_DEPTH {
log::warn!("file walk: {} exceeds depth {WALK_MAX_DEPTH}, skipped", dir.display());
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
for path in paths {
let Some(name) = path.file_name().and_then(|n| n.to_str()) 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 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.
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.is_file() {
if let Some(p) = path.to_str() {
out.push(p.to_string());
}
} else if meta.is_dir() {
walk_files(&path, depth + 1, out);
}
}
out
}
/// A file's display name — its basename without extension (`/sd/repo/notes.md`