lattice_picker/picker_sources.rs
1//! First-party picker source generators -- renderer-neutral; live
2//! in `lattice-picker` next to the `PickerSourceGenerator` trait
3//! and the `PickerRegistry` they register against. Symmetric with
4//! how feature crates already organise their sources
5//! (`lattice_snippet::picker_sources`, future
6//! `lattice_lsp::picker_sources`).
7//!
8//! Each source's state is reachable through `PickerContext` (the
9//! snapshot passed to `init` / `accept`) or via an `Arc`-cloned
10//! registry handle captured at construction (`CommandsSource` ->
11//! `CommandRegistry`, `GrepSource` -> `ConfigRegistry`). The trait
12//! surface stays state-handle-free.
13//!
14//! Slice 5.7.B.0 migrated this module out of `lattice-ui-tui`. The
15//! `walk_files_for_picker` helper (file-system walk for `:picker
16//! files`) lives here too -- it has no renderer dependency, and the
17//! only consumer today is `FilesSource`; the earlier ui-tui
18//! location was an accident of where the picker first landed.
19
20use std::sync::Arc;
21
22use lattice_completion::{
23 Annotation, AnnotationSegment, CandidateKind, KeybindingSource, KeymapReverseLookup,
24 RawCandidate,
25};
26use lattice_config::ConfigRegistry;
27use lattice_grammar::CommandRegistryHandle;
28use lattice_grammar::args::{ArgDefault, ArgSpec, Args};
29use lattice_grammar::command::{CommandKind, LatencyClass};
30use lattice_protocol::KeyChord;
31
32use crate::{
33 PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
34 RoutingPayload, SourceResult,
35};
36
37/// Format an ex-command's `args_schema` as the marginalia
38/// args hint -- emacs-style `<arg>` for required, `[<arg>]`
39/// for optional. Empty for no-arg commands. Used by
40/// `:picker commands` to fill the args column.
41fn format_args_hint(schema: &[ArgSpec]) -> String {
42 schema
43 .iter()
44 .map(|arg| match arg.default {
45 ArgDefault::Required => format!("<{}>", arg.name),
46 _ => format!("[<{}>]", arg.name),
47 })
48 .collect::<Vec<_>>()
49 .join(" ")
50}
51
52/// Format unix mode bits like `ls -l` (`-rw-r--r--`,
53/// `drwxr-xr-x`, `lrwxrwxrwx`). On platforms without unix
54/// mode bits, falls back to a six-char `<file>` / `<ro>`
55/// marker so the column stays width-aligned.
56// MARG §8: theme slot keys for file-metadata marginalia segments.
57// Must match the elements registered in `lattice-theme` (MR.2).
58const SLOT_PERM_TYPE: &str = "completion.annotation.perm.type";
59const SLOT_PERM_READ: &str = "completion.annotation.perm.read";
60const SLOT_PERM_WRITE: &str = "completion.annotation.perm.write";
61const SLOT_PERM_EXEC: &str = "completion.annotation.perm.exec";
62const SLOT_PERM_SPECIAL: &str = "completion.annotation.perm.special";
63const SLOT_PERM_NONE: &str = "completion.annotation.perm.none";
64const SLOT_SIZE: &str = "completion.annotation.size";
65const SLOT_MTIME: &str = "completion.annotation.mtime";
66
67fn perm_seg(ch: char, slot: &str) -> AnnotationSegment {
68 AnnotationSegment {
69 text: ch.to_string().into(),
70 slot: slot.into(),
71 }
72}
73
74/// MARG §8: build the `drwxr-xr-x` permission string as one segment
75/// per bit class, each tagged with its theme slot (the eza / `ls
76/// --color` coloring). The bit→slot policy lives here, once; both
77/// renderers just resolve each segment's slot. Returns 10 segments on
78/// unix (type char + 9 perm bits, with setuid/setgid/sticky folded
79/// into the exec positions as s/S/t/T); a 4-char `<ro>`/`<rw>` label on
80/// other platforms.
81fn perm_segments(meta: &std::fs::Metadata) -> Vec<AnnotationSegment> {
82 #[cfg(unix)]
83 {
84 use std::os::unix::fs::{FileTypeExt, PermissionsExt};
85 let mode = meta.permissions().mode();
86 let ft = meta.file_type();
87 let kind = if ft.is_dir() {
88 'd'
89 } else if ft.is_symlink() {
90 'l'
91 } else if ft.is_block_device() {
92 'b'
93 } else if ft.is_char_device() {
94 'c'
95 } else if ft.is_fifo() {
96 'p'
97 } else if ft.is_socket() {
98 's'
99 } else {
100 '-'
101 };
102 let mut out = Vec::with_capacity(10);
103 out.push(perm_seg(kind, SLOT_PERM_TYPE));
104 let rbit = |out: &mut Vec<AnnotationSegment>, set: bool| {
105 out.push(if set {
106 perm_seg('r', SLOT_PERM_READ)
107 } else {
108 perm_seg('-', SLOT_PERM_NONE)
109 });
110 };
111 let wbit = |out: &mut Vec<AnnotationSegment>, set: bool| {
112 out.push(if set {
113 perm_seg('w', SLOT_PERM_WRITE)
114 } else {
115 perm_seg('-', SLOT_PERM_NONE)
116 });
117 };
118 // exec-or-special: a set special bit (setuid/setgid/sticky)
119 // shows `lower` when exec is also set, `upper` otherwise.
120 let xbit = |out: &mut Vec<AnnotationSegment>,
121 exec: bool,
122 special: bool,
123 lower: char,
124 upper: char| {
125 if special {
126 out.push(perm_seg(
127 if exec { lower } else { upper },
128 SLOT_PERM_SPECIAL,
129 ));
130 } else if exec {
131 out.push(perm_seg('x', SLOT_PERM_EXEC));
132 } else {
133 out.push(perm_seg('-', SLOT_PERM_NONE));
134 }
135 };
136 rbit(&mut out, mode & 0o400 != 0);
137 wbit(&mut out, mode & 0o200 != 0);
138 xbit(&mut out, mode & 0o100 != 0, mode & 0o4000 != 0, 's', 'S');
139 rbit(&mut out, mode & 0o040 != 0);
140 wbit(&mut out, mode & 0o020 != 0);
141 xbit(&mut out, mode & 0o010 != 0, mode & 0o2000 != 0, 's', 'S');
142 rbit(&mut out, mode & 0o004 != 0);
143 wbit(&mut out, mode & 0o002 != 0);
144 xbit(&mut out, mode & 0o001 != 0, mode & 0o1000 != 0, 't', 'T');
145 out
146 }
147 #[cfg(not(unix))]
148 {
149 let label = if meta.permissions().readonly() {
150 "<ro>"
151 } else {
152 "<rw>"
153 };
154 label.chars().map(|c| perm_seg(c, SLOT_PERM_TYPE)).collect()
155 }
156}
157
158/// MARG §8: the file-metadata marginalia for one entry — a per-bit
159/// `perm` cell, a `size` cell, and (when `mtime` is available) an
160/// `mtime` cell, each an `Annotation::Styled` the renderer color-codes
161/// from its theme slot. Column order is fixed by `category_order`
162/// (perm → size → mtime). Single home so the file/dir picker and its
163/// test agree on the exact annotation set.
164fn metadata_annotations(meta: &std::fs::Metadata) -> Vec<Annotation> {
165 let mut annotations = vec![
166 Annotation::Styled {
167 category: "perm".into(),
168 segments: perm_segments(meta),
169 },
170 Annotation::Styled {
171 category: "size".into(),
172 segments: vec![AnnotationSegment {
173 text: format_size(meta.len()).into(),
174 slot: SLOT_SIZE.into(),
175 }],
176 },
177 ];
178 if let Ok(mt) = meta.modified() {
179 annotations.push(Annotation::Styled {
180 category: "mtime".into(),
181 segments: vec![AnnotationSegment {
182 text: format_mtime_relative(mt).into(),
183 slot: SLOT_MTIME.into(),
184 }],
185 });
186 }
187 annotations
188}
189
190// MARG §9: theme slot keys for the picker-rollout marginalia families.
191// Must match the elements registered in `lattice-theme` (MP.1). The
192// bit→slot / class→slot policy lives here once; renderers stay dumb.
193const SLOT_LOC_PATH: &str = "completion.annotation.location.path";
194const SLOT_LOC_LINE: &str = "completion.annotation.location.line";
195const SLOT_LOC_COL: &str = "completion.annotation.location.col";
196const SLOT_STATUS_DIRTY: &str = "completion.annotation.status.dirty";
197const SLOT_STATUS_ACTIVE: &str = "completion.annotation.status.active";
198const SLOT_LATENCY_REFLEX: &str = "completion.annotation.latency.reflex";
199const SLOT_LATENCY_DISPLAY: &str = "completion.annotation.latency.display";
200const SLOT_LATENCY_BACKGROUND: &str = "completion.annotation.latency.background";
201
202/// MARG §9: a marginalia segment from string text + a slot key.
203fn txt_seg(text: impl Into<String>, slot: &str) -> AnnotationSegment {
204 AnnotationSegment {
205 text: text.into().into(),
206 slot: slot.into(),
207 }
208}
209
210/// MARG §9: a colored `path:line:col` location cell — dim path, accent
211/// line, dim column, with the `:` separators riding the dim slots. A
212/// `None` path yields `line[:col]` (line/outline pickers); a `None`
213/// column drops the trailing `:col` (line-only pickers). The policy
214/// lives here so grep / jumps / outline / lines / marks (and the future
215/// LSP locations picker) share one coloring. Substrate helper, not a
216/// `Document` trait method — only specific picker sources consume it.
217fn location_segments(path: Option<&str>, line: u32, col: Option<u32>) -> Vec<AnnotationSegment> {
218 let mut out = Vec::with_capacity(5);
219 if let Some(p) = path {
220 out.push(txt_seg(p, SLOT_LOC_PATH));
221 out.push(txt_seg(":", SLOT_LOC_PATH));
222 }
223 out.push(txt_seg(line.to_string(), SLOT_LOC_LINE));
224 if let Some(c) = col {
225 out.push(txt_seg(":", SLOT_LOC_COL));
226 out.push(txt_seg(c.to_string(), SLOT_LOC_COL));
227 }
228 out
229}
230
231/// MARG §9: a `location` marginalia cell (`Styled`) wrapping
232/// [`location_segments`]. The shared shape for every coordinate picker.
233fn location_annotation(path: Option<&str>, line: u32, col: Option<u32>) -> Annotation {
234 Annotation::Styled {
235 category: "location".into(),
236 segments: location_segments(path, line, col),
237 }
238}
239
240/// PH.2: clone the host-collected syntax-highlight spans for
241/// buffer `line`, clipped to `display_len` (the candidate's
242/// shown byte length — the line text with its trailing `\n`
243/// trimmed). Spans are already line-relative `DisplaySpan`s, so
244/// they map 1:1 onto a `:picker lines` row whose `display` *is*
245/// the line. Absent / out-of-range line → no spans (plain
246/// preview). A clip landing mid-codepoint is dropped by the
247/// renderer's char-boundary guard, never a panic.
248fn display_spans_for_line(
249 highlights: &[Vec<lattice_completion::DisplaySpan>],
250 line: u32,
251 display_len: usize,
252) -> Vec<lattice_completion::DisplaySpan> {
253 let Some(spans) = highlights.get(line as usize) else {
254 return Vec::new();
255 };
256 spans
257 .iter()
258 .filter(|s| s.range.start < display_len)
259 .map(|s| lattice_completion::DisplaySpan {
260 range: s.range.start..s.range.end.min(display_len),
261 style: s.style,
262 })
263 .collect()
264}
265
266/// PH.2: project a line's syntax spans onto a symbol name shown
267/// in `:picker outline`. The symbol `display` is the name alone
268/// — a substring of its line starting at byte `col` — so the
269/// line-relative spans are clipped to `[col, col + name_len)`
270/// and shifted to be name-relative. Non-overlapping spans drop;
271/// partial overlaps clip. Absent line / no overlap → no spans
272/// (plain preview).
273fn display_spans_for_symbol(
274 highlights: &[Vec<lattice_completion::DisplaySpan>],
275 line: u32,
276 col: u32,
277 name_len: usize,
278) -> Vec<lattice_completion::DisplaySpan> {
279 let Some(spans) = highlights.get(line as usize) else {
280 return Vec::new();
281 };
282 let col = col as usize;
283 let end_bound = col.saturating_add(name_len);
284 spans
285 .iter()
286 .filter_map(|s| {
287 let start = s.range.start.max(col);
288 let end = s.range.end.min(end_bound);
289 if start >= end {
290 return None;
291 }
292 Some(lattice_completion::DisplaySpan {
293 range: (start - col)..(end - col),
294 style: s.style,
295 })
296 })
297 .collect()
298}
299
300/// MARG §9: buffer status markers — an active `•` and/or a dirty `+`,
301/// each in its own slot. Empty when neither applies (clean, inactive).
302fn status_segments(dirty: bool, active: bool) -> Vec<AnnotationSegment> {
303 let mut out = Vec::with_capacity(2);
304 if active {
305 out.push(txt_seg("•", SLOT_STATUS_ACTIVE));
306 }
307 if dirty {
308 out.push(txt_seg("+", SLOT_STATUS_DIRTY));
309 }
310 out
311}
312
313/// MARG §9: a single latency-class marginalia segment, color-coded by
314/// the canonical `lattice_grammar` latency class (no duplicate enum).
315fn latency_segment(class: LatencyClass) -> AnnotationSegment {
316 let (text, slot) = match class {
317 LatencyClass::Reflex => ("[reflex]", SLOT_LATENCY_REFLEX),
318 LatencyClass::Display => ("[display]", SLOT_LATENCY_DISPLAY),
319 LatencyClass::Background => ("[background]", SLOT_LATENCY_BACKGROUND),
320 };
321 txt_seg(text, slot)
322}
323
324/// MARG §9: slot for the command argument-hint marginalia cell.
325const SLOT_ARGS: &str = "completion.annotation.args";
326
327/// MARG §9: slot for the buffer-id (`#N`) marginalia cell.
328const SLOT_BUFFER_ID: &str = "completion.annotation.buffer-id";
329
330/// MARG §9: slot for the register / mark name marginalia cell.
331const SLOT_REGISTER: &str = "completion.annotation.register";
332
333/// Format a byte size with a single-letter SI-ish suffix
334/// (`72` / `1.4K` / `70k` / `12M` / `4.2G`), matching the
335/// `ls -h` convention. Uses 1024-based units. Capped at 5
336/// chars so the size column has a stable width.
337fn format_size(bytes: u64) -> String {
338 const KB: u64 = 1024;
339 const MB: u64 = KB * 1024;
340 const GB: u64 = MB * 1024;
341 if bytes < KB {
342 format!("{bytes}")
343 } else if bytes < MB {
344 let k = bytes as f64 / KB as f64;
345 if k < 10.0 {
346 format!("{k:.1}K")
347 } else {
348 format!("{}K", bytes / KB)
349 }
350 } else if bytes < GB {
351 let m = bytes as f64 / MB as f64;
352 if m < 10.0 {
353 format!("{m:.1}M")
354 } else {
355 format!("{}M", bytes / MB)
356 }
357 } else {
358 let g = bytes as f64 / GB as f64;
359 if g < 10.0 {
360 format!("{g:.1}G")
361 } else {
362 format!("{}G", bytes / GB)
363 }
364 }
365}
366
367/// Format a `SystemTime` as a relative-to-now phrase
368/// (`28 hours ago`, `3 days ago`, `just now`). Stable
369/// across reasonable clock skew (negative durations clamp
370/// to "just now" rather than producing nonsense). Returns
371/// a fixed-format string so columns align.
372fn format_mtime_relative(mtime: std::time::SystemTime) -> String {
373 let now = std::time::SystemTime::now();
374 let secs = match now.duration_since(mtime) {
375 Ok(d) => d.as_secs(),
376 Err(_) => return "just now".to_string(),
377 };
378 if secs < 60 {
379 "just now".to_string()
380 } else if secs < 60 * 60 {
381 let m = secs / 60;
382 if m == 1 {
383 "1 minute ago".to_string()
384 } else {
385 format!("{m} minutes ago")
386 }
387 } else if secs < 60 * 60 * 36 {
388 // Hours up to 36h, matching moment.js / emacs
389 // marginalia convention (so a file edited yesterday
390 // afternoon reads "28 hours ago" instead of
391 // jumping to "1 day ago" at the 24h boundary).
392 let h = secs / (60 * 60);
393 if h == 1 {
394 "1 hour ago".to_string()
395 } else {
396 format!("{h} hours ago")
397 }
398 } else if secs < 60 * 60 * 24 * 30 {
399 let d = secs / (60 * 60 * 24);
400 if d == 1 {
401 "1 day ago".to_string()
402 } else {
403 format!("{d} days ago")
404 }
405 } else if secs < 60 * 60 * 24 * 365 {
406 let mo = secs / (60 * 60 * 24 * 30);
407 if mo == 1 {
408 "1 month ago".to_string()
409 } else {
410 format!("{mo} months ago")
411 }
412 } else {
413 let y = secs / (60 * 60 * 24 * 365);
414 if y == 1 {
415 "1 year ago".to_string()
416 } else {
417 format!("{y} years ago")
418 }
419 }
420}
421
422/// `:picker files [root]`. Walks `root` (or the workspace
423/// root from the context) and emits one row per regular file
424/// under the standard ignore set (`.git`, `target`,
425/// `node_modules`, `dist`, `.cache`). Capped at
426/// `FILE_PICKER_MAX_ENTRIES` (5000) -- larger workspaces fall
427/// back to `:picker grep`.
428/// The root a rooted picker walks: the caller's explicit argument when there
429/// is one, else the resolved workspace root.
430///
431/// `~` expands, as it does everywhere else a user writes a path. Without it
432/// `:picker files ~/notes` walked a directory literally named `~`, found
433/// nothing, and reported "no files under ~/notes" — a message that blames the
434/// directory for being empty rather than the path for never having resolved.
435///
436/// A relative argument is left relative: `canonicalize` resolves it against
437/// the process cwd, which is what a relative path typed at a picker prompt
438/// already meant, and this is a bug fix rather than a change of meaning.
439fn explicit_root_or(args: &[String], workspace_root: &std::path::Path) -> std::path::PathBuf {
440 match args.first() {
441 Some(p) if !p.is_empty() => std::path::PathBuf::from(lattice_core::home::expand_tilde(p)),
442 _ => workspace_root.to_path_buf(),
443 }
444}
445
446pub struct FilesSource {
447 pub spec: PickerSourceSpec,
448}
449
450impl FilesSource {
451 pub fn new() -> Self {
452 use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
453 Self {
454 spec: PickerSourceSpec {
455 create_label: None,
456 delete_command: None,
457 help_topic: None,
458 id: "files".into(),
459 // PP.2: the list IS the project. `:files` in one checkout and
460 // `:files` in another answer entirely differently, and nothing
461 // else on screen says which one answered.
462 rooted: true,
463 doc: "File picker rooted at the active buffer's PROJECT (recursive). Pass an explicit path to override.".into(),
464 args_hint: "[root]".into(),
465 args_schema: vec![ArgSpec {
466 name: "root".into(),
467 kind: ArgKind::String,
468 doc: "Directory to walk recursively. Absent = the active buffer's project root.".into(),
469 prompt: "root:".into(),
470 default: ArgDefault::None,
471 completion: Some("gen:files".into()),
472 picker: None,
473 }],
474 live: false,
475 },
476 }
477 }
478}
479
480impl Default for FilesSource {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486impl PickerSourceGenerator for FilesSource {
487 fn spec(&self) -> &PickerSourceSpec {
488 &self.spec
489 }
490
491 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
492 // PR.4: the active buffer's PROJECT root, resolved by the host
493 // (`picker_workspace_root_path`).
494 //
495 // The two answers this replaces were both wrong in opposite
496 // directions, and the history is worth keeping because the
497 // pendulum swung once already: an early slice used the active
498 // document's parent, which "behaved unintuitively for projects
499 // spread across many subdirectories"; the fix was the process
500 // cwd, which is right only if you launched the editor in the
501 // tree you are editing. The project root is the answer both
502 // were reaching for.
503 //
504 // An explicit `:picker files <path>` still wins — that is the
505 // user saying "not that project, this one".
506 let root = explicit_root_or(args, &ctx.workspace_root);
507 let canonical_root = std::fs::canonicalize(&root).unwrap_or(root.clone());
508 // Slice C: serve the warmed session cache instantly when present. A
509 // stale entry (older than the TTL) is still served immediately, with a
510 // background re-walk kicked off so the NEXT open reflects on-disk
511 // changes — the open never blocks on the walk once warmed. A cold cache
512 // walks now and populates it. All of this is off the UI thread (init
513 // runs on a picker worker); the refresh thread keeps it that way.
514 let entries = match cached_files(&canonical_root) {
515 Some((cached, stale)) => {
516 if stale {
517 let refresh_root = canonical_root.clone();
518 std::thread::spawn(move || warm_files_cache(&refresh_root));
519 }
520 cached
521 }
522 None => {
523 let walked = walk_files_for_picker(&canonical_root);
524 if let Ok(mut cache) = file_walk_cache().lock() {
525 cache.insert(
526 canonical_root.clone(),
527 (walked.clone(), std::time::Instant::now()),
528 );
529 }
530 walked
531 }
532 };
533 if entries.is_empty() {
534 return Err(format!(
535 "files: no files under {}",
536 canonical_root.display()
537 ));
538 }
539 // MARG §8: the marginalia (perms / size / mtime) is attached as typed
540 // `Annotation::Styled` cells — the renderer color-codes each per its
541 // theme slot (per-bit permission colors, gold size, green mtime). The
542 // metadata was gathered by `walk_files_for_picker` DURING the parallel
543 // walk (reusing the walk's own stat), so there is no separate ≤5000-stat
544 // pass here — that eager pass was the bulk of the first-open latency.
545 // The candidate `display` is just the path (so fuzzy matching runs on
546 // the path, not the metadata text); column alignment comes from
547 // `AnnotationColumns`. A file whose metadata could not be read carries
548 // no annotations → blank cells, the path still shows.
549 let pairs = entries
550 .into_iter()
551 .map(|(abs, meta)| {
552 let rel = abs
553 .strip_prefix(&canonical_root)
554 .map(|p| p.to_path_buf())
555 .unwrap_or_else(|_| abs.clone());
556 let rel_display = rel.display().to_string();
557 let annotations = meta.as_ref().map(metadata_annotations).unwrap_or_default();
558 let mut cand = RawCandidate::plain(rel_display, CandidateKind::Plain);
559 cand.annotations = annotations;
560 // Slice 7b.2: typed accept payload.
561 cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::OpenFile {
562 path: abs.clone(),
563 }));
564 (cand, RoutingPayload::OpenFile { path: abs })
565 })
566 .collect();
567 Ok(PickerInitResult::Inline(pairs))
568 }
569
570 fn accept(
571 &self,
572 _ctx: &PickerContext<'_>,
573 routing: &RoutingPayload,
574 ) -> SourceResult<PickerAcceptOutcome> {
575 match routing {
576 RoutingPayload::OpenFile { path } => {
577 Ok(PickerAcceptOutcome::OpenFile { path: path.clone() })
578 }
579 other => Err(format!("files: unexpected routing payload {other:?}")),
580 }
581 }
582}
583
584/// `:picker file-pick [root]`. MG.53.e — the same walk as
585/// [`FilesSource`], accepting to a **value** instead of to an open
586/// buffer.
587///
588/// The two differ only in what accept means, and that difference is the
589/// whole reason this exists: `FilesSource` hands its path to `do_edit`,
590/// i.e. it opens the file, where a caller asking "which file?" needs the
591/// path itself. magit's `File (repo-relative):` argument was a free-text
592/// prompt because of that one mismatch — the listing was always
593/// reusable, the accept never was.
594///
595/// The path is emitted **relative to the walk root**, because the
596/// consumers are git commands and git addresses files repo-relatively.
597/// An absolute path would work by luck for the common case (the root is
598/// the repo) and break the moment it is not.
599///
600/// Registered in the host rather than in `lattice-magit` so every
601/// provider wanting "choose a file, then act" reaches it through the
602/// same `PickerSourceSpec` surface, including WASM ones. A magit-local
603/// copy would have put a second consumer of the repo file walk inside a
604/// feature crate and bought nothing but a smaller diff.
605pub struct FilePickSource {
606 pub spec: PickerSourceSpec,
607}
608
609/// The source id, shared by the generator and every declaration that
610/// names it — one constant so a rename cannot leave a transient
611/// pointing at a source that no longer exists.
612pub const FILE_PICK_SOURCE: &str = "file-pick";
613
614impl FilePickSource {
615 pub fn new() -> Self {
616 use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
617 Self {
618 spec: PickerSourceSpec {
619 create_label: None,
620 delete_command: None,
621 help_topic: None,
622 id: FILE_PICK_SOURCE.into(),
623 // Same walk as `files`, so the same root and the same reason.
624 rooted: true,
625 doc: "Pick a file and supply its path as a value (for a transient argument or \
626 other caller awaiting one). Lists the same files as `files`; differs only \
627 in that accepting yields the path rather than opening it."
628 .into(),
629 args_hint: "[root]".into(),
630 args_schema: vec![ArgSpec {
631 name: "root".into(),
632 kind: ArgKind::String,
633 doc: "Directory to walk recursively. Absent = the active buffer's \
634 project root. Picked paths are relative to it."
635 .into(),
636 prompt: "root:".into(),
637 default: ArgDefault::None,
638 completion: Some("gen:files".into()),
639 picker: None,
640 }],
641 live: false,
642 },
643 }
644 }
645}
646
647impl Default for FilePickSource {
648 fn default() -> Self {
649 Self::new()
650 }
651}
652
653/// PC.9: `dir-pick` — [`FilePickSource`]'s directory peer. Browse to a
654/// directory and supply its path as a value.
655///
656/// ## Incremental, not a walk
657///
658/// The candidates are the children of the directory the query names, filtered
659/// by the basename it ends with — `gen:directories`' model, which is what
660/// emacs's `read-directory-name` does. It shares the implementation with that
661/// generator ([`lattice_completion::builtins::generators::path_entries`])
662/// rather than copying it, so `<Tab>` on the `:` line and this picker cannot
663/// disagree about what listing a path means.
664///
665/// [`walk_files_for_picker`] with directories instead of files was the obvious
666/// alternative and is the wrong one here: it has no depth cap and a flat
667/// [`FILE_PICKER_MAX_ENTRIES`] ceiling, so pointed anywhere near a home
668/// directory it stops somewhere arbitrary and the directory you wanted may
669/// simply not be in the list. Incremental has no ceiling, reaches any depth,
670/// and opens in one `read_dir`.
671///
672/// ## It starts at HOME, where `file-pick` starts at the workspace root
673///
674/// Not an inconsistency. Picking a *file* is nearly always picking one in the
675/// project you are in, so the workspace root is the useful default. Picking a
676/// *directory* is nearly always about going somewhere you are **not** — the
677/// motivating case is choosing a project you have never opened — and rooting
678/// that at the project you are already in would make the common case start in
679/// the one place it does not want.
680///
681/// An explicit `start` argument still wins, and `:picker dir-pick .` is the
682/// spelling for "here".
683///
684/// ## Tilde survives into the rows on purpose
685///
686/// A row's `text` keeps whatever spelling the query used (`~/src/…`), because
687/// that is what the user is reading and typing against, and descending
688/// re-lists from it unchanged. The value handed back on accept is the
689/// **expanded** absolute path off `CandidateData::File`, because a consumer
690/// resolving it has no obligation to know about `~`.
691pub struct DirPickSource {
692 pub spec: PickerSourceSpec,
693}
694
695/// The source id, shared by the generator and every declaration that names it.
696pub const DIR_PICK_SOURCE: &str = "dir-pick";
697
698/// What PP.1's go-up row displays — and, since PP.3, how it is RECOGNISED.
699///
700/// One constant rather than two literals: `accept_navigates` decides from the
701/// display, so a row that rendered `..` while the hook looked for `../` would
702/// be a `<CR>` that silently went back to choosing the parent.
703pub const PARENT_ROW_DISPLAY: &str = "../";
704
705impl DirPickSource {
706 pub fn new() -> Self {
707 use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
708 Self {
709 spec: PickerSourceSpec {
710 create_label: None,
711 delete_command: None,
712 help_topic: None,
713 id: DIR_PICK_SOURCE.into(),
714 // PP.2: NOT rooted, despite being the most path-shaped source
715 // there is. Its query is the directory it is listing, so the
716 // prompt already says where it is — a root beside that would
717 // be a second answer to the same question, and a staler one
718 // (it would name where browsing STARTED, not where you are).
719 rooted: false,
720 doc: "Browse to a directory and supply its path as a value (for a transient \
721 argument, a command argument, or other caller awaiting one). Lists one \
722 level at a time: `<Tab>` (or `<C-l>`) descends into the selected \
723 directory, `<C-w>` goes up, `<CR>` chooses — except on the `../` row, \
724 where it goes up."
725 .into(),
726 args_hint: "[start]".into(),
727 args_schema: vec![ArgSpec {
728 name: "start".into(),
729 kind: ArgKind::String,
730 doc: "Directory to start browsing from. Absent = the home directory, \
731 because choosing a directory is usually about going somewhere you \
732 are not."
733 .into(),
734 prompt: "start:".into(),
735 default: ArgDefault::None,
736 completion: Some("gen:directories".into()),
737 picker: None,
738 }],
739 // The source owns its filtering: the query is a PATH, and fuzzy
740 // matching a path prefix against bare child names would rank
741 // `~/src/dh` against `dhruvasagar` rather than listing what is
742 // under `~/src/`.
743 live: true,
744 },
745 }
746 }
747
748 /// The prefix `path_entries` should list for `query`.
749 ///
750 /// An empty query means "show me `start`", and it is spelled as a prefix
751 /// ending in `/` so the rows come back carrying their full path rather
752 /// than bare names — which is what makes the first `<C-l>` work like every
753 /// later one.
754 fn prefix_for(start: &str, query: &str) -> String {
755 if query.is_empty() {
756 let trimmed = start.trim_end_matches('/');
757 format!("{trimmed}/")
758 } else {
759 query.to_string()
760 }
761 }
762
763 /// The directory one level above `prefix`, spelled the way the query
764 /// spells it.
765 ///
766 /// Shared by `<C-w>` and the `../` row so the two cannot disagree about
767 /// where "up" is — two ways to go up that arrive somewhere different is
768 /// the kind of inconsistency nobody reports and everybody trips on.
769 ///
770 /// The trailing `/` comes off first, or `~/src/` would resolve its own
771 /// last component and go nowhere.
772 fn parent_of(prefix: &str) -> Option<String> {
773 let trimmed = prefix.strip_suffix('/').unwrap_or(prefix);
774 if trimmed.is_empty() {
775 // `/`. There is nothing above the root, and pretending otherwise
776 // would silently relocate the user somewhere they did not ask for.
777 return None;
778 }
779 match trimmed.rfind('/') {
780 // `/tmp` → `/`, keeping the separator that makes it a listing.
781 Some(0) => Some("/".to_string()),
782 Some(i) => Some(trimmed[..=i].to_string()),
783 // No separator left in the spelling: `~`, the one case where the
784 // query's own text cannot name its parent. Resolve it and answer
785 // absolutely, rather than reporting that the home directory has no
786 // parent — ascend (then on `<C-h>`) at `~/` used to clear the query, which re-listed
787 // `~/` and so read as a key that did nothing.
788 //
789 // A bare word (a query the user typed over) is not a path we can
790 // resolve, and guessing at one would move them somewhere arbitrary.
791 None => {
792 let absolute = lattice_core::home::expand_tilde(trimmed);
793 let path = std::path::Path::new(&absolute);
794 if !path.is_absolute() {
795 return None;
796 }
797 path.parent().map(|p| {
798 let s = p.to_string_lossy();
799 if s.ends_with('/') {
800 s.into_owned()
801 } else {
802 format!("{s}/")
803 }
804 })
805 }
806 }
807 }
808
809 /// PP.1: the `../` row.
810 ///
811 /// An ORDINARY row whose text is the parent's path, which is what makes it
812 /// need no special-casing anywhere else: `<C-l>` descends into it because
813 /// the text ends in `/`, and `<CR>` supplies the parent because that is
814 /// what every other row does with its own path. A synthetic "go up" row
815 /// with its own accept semantics would be a second answer to a question
816 /// `descend` already answers.
817 ///
818 /// **Only when `prefix` names a whole directory** (it ends in `/`). Once
819 /// the user has typed a basename the listing is a filter over children,
820 /// and a `../` surviving the filter would be the one row in it that is not
821 /// a match.
822 fn parent_row(prefix: &str) -> Option<(RawCandidate, RoutingPayload)> {
823 if !prefix.ends_with('/') {
824 return None;
825 }
826 let parent = Self::parent_of(prefix)?;
827 let expanded = std::path::PathBuf::from(lattice_core::home::expand_tilde(&parent));
828 Some((
829 RawCandidate {
830 insert_text: None,
831 text: parent,
832 // `../`, not the path it resolves to. The path is already in
833 // the prompt (the query); what this row adds is the verb — and
834 // since PP.3 the display is also how `accept_navigates`
835 // recognises the row, hence the constant.
836 display: PARENT_ROW_DISPLAY.to_string(),
837 // Built the way `path_entries` builds a directory — same kind,
838 // same `CandidateData::File`, same empty annotations — because
839 // everything downstream (the icon, `descend`, the accept) reads
840 // those and must not be able to tell this row apart.
841 kind: CandidateKind::Directory,
842 data: lattice_completion::CandidateData::File {
843 path: expanded.clone(),
844 is_dir: true,
845 size: None,
846 },
847 source: None,
848 accept_action: None,
849 annotations: Vec::new(),
850 display_spans: Vec::new(),
851 },
852 RoutingPayload::SuppliedValue {
853 value: expanded.to_string_lossy().to_string(),
854 },
855 ))
856 }
857
858 /// Rows for `prefix`. Directories only, each carrying its expanded
859 /// absolute path as the value it supplies, `../` first.
860 ///
861 /// **`../` belongs to a directory that exists.** A query naming nothing
862 /// yields an empty list — this source's contract, and the reason it does
863 /// not spend its life reporting failure while you type a path — and a
864 /// lone `../` there would suggest the path resolved when it did not. The
865 /// `is_dir` stat is paid only when the listing came back empty, which is
866 /// the one case where "no children" and "no directory" are not the same
867 /// thing.
868 fn rows(prefix: &str) -> Vec<(RawCandidate, RoutingPayload)> {
869 let children = Self::child_rows(prefix);
870 let parent = if children.is_empty()
871 && !std::path::Path::new(&lattice_core::home::expand_tilde(prefix)).is_dir()
872 {
873 None
874 } else {
875 Self::parent_row(prefix)
876 };
877 parent.into_iter().chain(children).collect()
878 }
879
880 /// The real entries — everything [`rows`](Self::rows) lists apart from
881 /// `../`. Split out because `init`'s "cannot read this directory" check
882 /// asks whether the listing is empty, and a `../` row is present whether
883 /// or not the directory can be read.
884 fn child_rows(prefix: &str) -> Vec<(RawCandidate, RoutingPayload)> {
885 lattice_completion::builtins::generators::path_entries(prefix, false, false)
886 .into_iter()
887 .map(|cand| {
888 // The expanded path off the entry, not `cand.text` — the text
889 // may be spelled with `~` and a consumer resolving it should
890 // not have to know that.
891 let value = match &cand.data {
892 lattice_completion::CandidateData::File { path, .. } => {
893 path.to_string_lossy().to_string()
894 }
895 // `path_entries` only ever emits `File`; if that changes,
896 // the row's own text is the honest fallback rather than a
897 // panic on a picker keystroke.
898 _ => cand.text.clone(),
899 };
900 (cand, RoutingPayload::SuppliedValue { value })
901 })
902 .collect()
903 }
904
905 /// Where browsing begins: the explicit argument, else home.
906 fn start_dir(args: &[String]) -> String {
907 match args.first() {
908 Some(p) if !p.is_empty() => p.clone(),
909 _ => "~".to_string(),
910 }
911 }
912}
913
914impl Default for DirPickSource {
915 fn default() -> Self {
916 Self::new()
917 }
918}
919
920impl PickerSourceGenerator for DirPickSource {
921 fn spec(&self) -> &PickerSourceSpec {
922 &self.spec
923 }
924
925 fn init(&self, _ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
926 let start = Self::start_dir(args);
927 let prefix = Self::prefix_for(&start, "");
928 // An unreadable start IS an error, unlike an unreadable query: the
929 // caller named this one, and opening an empty picker over a directory
930 // that does not exist would report nothing at all.
931 //
932 // Asked of the CHILDREN, not of `rows`: PP.1's `../` is present
933 // whether or not the directory can be read, so `rows` is never empty
934 // and this check would never fire again.
935 if Self::child_rows(&prefix).is_empty()
936 && !std::path::Path::new(&lattice_core::home::expand_tilde(&start)).is_dir()
937 {
938 return Err(format!("{DIR_PICK_SOURCE}: cannot read {start}"));
939 }
940 Ok(PickerInitResult::Inline(Self::rows(&prefix)))
941 }
942
943 /// Re-list on every keystroke. An unreadable query yields an EMPTY list,
944 /// not an error: half a typed path names nothing yet, and that is the
945 /// state the user is in for most of the keystrokes — erroring on it would
946 /// mean the picker spends its life reporting failure.
947 fn on_query_changed(
948 &self,
949 _ctx: &PickerContext<'_>,
950 query: &str,
951 ) -> Option<SourceResult<PickerInitResult>> {
952 // No args here — a live source is a shared generator with no per-open
953 // state, so `start` is unavailable once the query is non-empty. It
954 // does not need to be: a non-empty query is itself an absolute or
955 // tilde-spelled path, because that is what the rows carry.
956 let prefix = if query.is_empty() {
957 Self::prefix_for("~", "")
958 } else {
959 query.to_string()
960 };
961 Some(Ok(PickerInitResult::Inline(Self::rows(&prefix))))
962 }
963
964 /// `<C-l>`: the selected row's own text becomes the query, so the next
965 /// listing is of its children. It already ends in `/` — `path_entries`
966 /// puts one on every directory — which is exactly the prefix that lists a
967 /// directory's contents rather than its siblings.
968 fn descend(&self, _ctx: &PickerContext<'_>, candidate: &RawCandidate) -> Option<String> {
969 candidate
970 .text
971 .ends_with('/')
972 .then(|| candidate.text.clone())
973 }
974
975 /// PP.3: `<CR>` on `../` GOES UP. It does not choose the parent.
976 ///
977 /// PP.1 shipped the other reading — `../` is an ordinary row, so `<CR>`
978 /// supplies its path like every other row does — and it was wrong in the
979 /// way that only shows up in use. `../` reads as a verb, every file
980 /// browser there is (netrw, oil, ranger, lf, telescope-file-browser)
981 /// treats `<CR>` on `..` as "go up", and the UX-convention rule says
982 /// muscle memory wins on a surface like this one.
983 ///
984 /// What it looked like in practice: `<CR>` on `../` at `~/` supplied
985 /// `/Users`, which the project flow then refused — an error message where
986 /// the user had asked to go up a level.
987 ///
988 /// Only this row. Every other row in this picker is a directory you might
989 /// be choosing, and `<CR>` still chooses it.
990 fn accept_navigates(
991 &self,
992 _ctx: &PickerContext<'_>,
993 candidate: &RawCandidate,
994 ) -> Option<String> {
995 (candidate.display == PARENT_ROW_DISPLAY).then(|| candidate.text.clone())
996 }
997
998 /// `<C-w>`: drop the last path component.
999 ///
1000 /// [`parent_of`](Self::parent_of) does the work, shared with the `../`
1001 /// row so the key and the row cannot land in different places. `/` stays
1002 /// a fixed point — `parent_of` answers `None` there, and this returns the
1003 /// query unchanged so the host recognises it and spends no re-query.
1004 fn ascend(&self, query: &str) -> Option<String> {
1005 if query == "/" {
1006 return Some("/".to_string());
1007 }
1008 Self::parent_of(query)
1009 }
1010
1011 /// PP.1: open on the start directory rather than on an empty query.
1012 ///
1013 /// The query IS the directory being listed here, so an empty one leaves
1014 /// the prompt unable to say where you are — every row carries a path and
1015 /// the one line meant to orient you carries nothing. It also left ascend
1016 /// with no last component to drop, so the first press did nothing and the
1017 /// second worked.
1018 ///
1019 /// The trailing `/` is what makes it a LISTING rather than a filter:
1020 /// `path_entries("~/src")` lists `~`'s children whose names start with
1021 /// `src`, where `path_entries("~/src/")` lists what is inside. Seeding
1022 /// the un-slashed form is the bug this normalisation exists to prevent,
1023 /// and `:picker dir-pick /tmp` walked straight into it.
1024 fn initial_query(&self, args: &[String]) -> Option<String> {
1025 Some(Self::prefix_for(&Self::start_dir(args), ""))
1026 }
1027
1028 fn accept(
1029 &self,
1030 _ctx: &PickerContext<'_>,
1031 routing: &RoutingPayload,
1032 ) -> SourceResult<PickerAcceptOutcome> {
1033 match routing {
1034 RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1035 text: value.clone(),
1036 }),
1037 other => Err(format!(
1038 "{DIR_PICK_SOURCE}: unexpected routing payload {other:?}"
1039 )),
1040 }
1041 }
1042}
1043
1044impl PickerSourceGenerator for FilePickSource {
1045 fn spec(&self) -> &PickerSourceSpec {
1046 &self.spec
1047 }
1048
1049 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1050 // PR.4: as above — the resolved project root, with an explicit
1051 // argument still winning.
1052 let root = explicit_root_or(args, &ctx.workspace_root);
1053 let canonical_root = std::fs::canonicalize(&root).unwrap_or(root.clone());
1054 let entries = walk_files_for_picker(&canonical_root);
1055 if entries.is_empty() {
1056 return Err(format!(
1057 "{FILE_PICK_SOURCE}: no files under {}",
1058 canonical_root.display()
1059 ));
1060 }
1061 let pairs = entries
1062 .into_iter()
1063 .map(|(abs, _meta)| {
1064 let rel = abs
1065 .strip_prefix(&canonical_root)
1066 .map(|p| p.to_path_buf())
1067 .unwrap_or_else(|_| abs.clone());
1068 let rel_display = rel.display().to_string();
1069 // No `accept_action`: this source supplies a value, and
1070 // an `AcceptAction::OpenFile` here would let the
1071 // completion layer open the file behind the caller's
1072 // back — the exact confusion this source exists to
1073 // avoid.
1074 let cand = RawCandidate::plain(rel_display.clone(), CandidateKind::Plain);
1075 (cand, RoutingPayload::SuppliedValue { value: rel_display })
1076 })
1077 .collect();
1078 Ok(PickerInitResult::Inline(pairs))
1079 }
1080
1081 fn accept(
1082 &self,
1083 _ctx: &PickerContext<'_>,
1084 routing: &RoutingPayload,
1085 ) -> SourceResult<PickerAcceptOutcome> {
1086 match routing {
1087 RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1088 text: value.clone(),
1089 }),
1090 other => Err(format!(
1091 "{FILE_PICK_SOURCE}: unexpected routing payload {other:?}"
1092 )),
1093 }
1094 }
1095}
1096
1097/// `:picker yank-ring`. YR.4 — the yank ring and the live named
1098/// registers, in one list.
1099///
1100/// Both are "text you already copied", and which of the two a given
1101/// piece of text is in is an implementation detail of how you copied it.
1102/// Splitting them across two pickers would make the user answer that
1103/// question before they can look.
1104///
1105/// Accept returns the text through [`PickerAcceptOutcome::FillCaller`],
1106/// so where it lands is whatever opened the picker — the document, the
1107/// `:` line, a prompt, a transient argument, another picker's query. The
1108/// source does not know and must not decide.
1109pub struct YankRingSource {
1110 pub spec: PickerSourceSpec,
1111}
1112
1113pub const YANK_RING_SOURCE: &str = "yank-ring";
1114
1115impl YankRingSource {
1116 pub fn new() -> Self {
1117 Self {
1118 spec: PickerSourceSpec::no_args(
1119 YANK_RING_SOURCE,
1120 "Yank ring and named registers — pick previously copied text and \
1121 insert it wherever the picker was opened from.",
1122 ),
1123 }
1124 }
1125}
1126
1127impl Default for YankRingSource {
1128 fn default() -> Self {
1129 Self::new()
1130 }
1131}
1132
1133impl PickerSourceGenerator for YankRingSource {
1134 fn spec(&self) -> &PickerSourceSpec {
1135 &self.spec
1136 }
1137
1138 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1139 let mut pairs: Vec<(RawCandidate, RoutingPayload)> = Vec::new();
1140
1141 // Ring first, newest first: the thing you just copied is the
1142 // thing you are most likely reaching for.
1143 for (i, (content, linewise)) in ctx.yank_ring.iter().enumerate() {
1144 let mut cand = RawCandidate::plain(one_line_preview(content), CandidateKind::Plain);
1145 cand.annotations = vec![
1146 // Position is the address you would have used: the newest
1147 // entry is what `"0` will name once YR.2 lands.
1148 Annotation::Styled {
1149 category: "register".into(),
1150 segments: vec![txt_seg(format!("{i}"), SLOT_REGISTER)],
1151 },
1152 // Kind is not decoration. A linewise entry pastes on its
1153 // own line and a charwise one pastes inline, so hiding it
1154 // makes paste unpredictable at the exact moment the user
1155 // is choosing between two rows that look alike.
1156 Annotation::Styled {
1157 category: "yank-kind".into(),
1158 segments: vec![txt_seg(
1159 if *linewise { "line" } else { "char" }.to_string(),
1160 SLOT_REGISTER,
1161 )],
1162 },
1163 ];
1164 pairs.push((
1165 cand,
1166 RoutingPayload::SuppliedValue {
1167 value: content.clone(),
1168 },
1169 ));
1170 }
1171
1172 // Then the named registers, which are addressed rather than
1173 // recent. `ctx.registers` carries previews rather than full
1174 // content, so these rows can only offer what the preview holds —
1175 // noted here because it is a real limit, not an oversight: the
1176 // register's full text is re-read host-side by the paste path,
1177 // which this accept deliberately does not use.
1178 for (name, preview) in &ctx.registers {
1179 let mut cand = RawCandidate::plain(one_line_preview(preview), CandidateKind::Plain);
1180 cand.annotations = vec![Annotation::Styled {
1181 category: "register".into(),
1182 segments: vec![txt_seg(format!("\"{name}"), SLOT_REGISTER)],
1183 }];
1184 pairs.push((
1185 cand,
1186 RoutingPayload::SuppliedValue {
1187 value: preview.clone(),
1188 },
1189 ));
1190 }
1191
1192 if pairs.is_empty() {
1193 return Err("yank-ring: nothing has been yanked or deleted yet".into());
1194 }
1195 Ok(PickerInitResult::Inline(pairs))
1196 }
1197
1198 fn accept(
1199 &self,
1200 _ctx: &PickerContext<'_>,
1201 routing: &RoutingPayload,
1202 ) -> SourceResult<PickerAcceptOutcome> {
1203 match routing {
1204 RoutingPayload::SuppliedValue { value } => Ok(PickerAcceptOutcome::FillCaller {
1205 text: value.clone(),
1206 }),
1207 other => Err(format!(
1208 "{YANK_RING_SOURCE}: unexpected routing payload {other:?}"
1209 )),
1210 }
1211 }
1212}
1213
1214/// Collapse an entry to one matchable, renderable line.
1215///
1216/// A yank is frequently multi-line, and a picker row is one line — so
1217/// without this the list renders broken and the fuzzy matcher scores
1218/// against embedded newlines. The full text is still what accept
1219/// returns; only the display is folded.
1220fn one_line_preview(text: &str) -> String {
1221 let flat: String = text
1222 .lines()
1223 .map(str::trim_end)
1224 .filter(|l| !l.is_empty())
1225 .collect::<Vec<_>>()
1226 .join(" ⏎ ");
1227 if flat.chars().count() > 120 {
1228 let head: String = flat.chars().take(117).collect();
1229 format!("{head}...")
1230 } else if flat.is_empty() {
1231 // Whitespace-only yanks are real and worth being able to pick
1232 // back; an empty row would be indistinguishable from a bug.
1233 format!("<{} blank chars>", text.chars().count())
1234 } else {
1235 flat
1236 }
1237}
1238
1239/// `:picker recent`. Walks `ctx.recent_files` (MRU, newest
1240/// first) and emits one row per path. Empty MRU returns
1241/// `Err("no recent files")` which the host echoes.
1242pub struct RecentFilesSource {
1243 pub spec: PickerSourceSpec,
1244}
1245
1246impl RecentFilesSource {
1247 pub fn new() -> Self {
1248 Self {
1249 spec: PickerSourceSpec::no_args(
1250 "recent",
1251 "Recently-edited files (MRU). Walks `App.recent_files`; accept edits the chosen path.",
1252 ),
1253 }
1254 }
1255}
1256
1257impl Default for RecentFilesSource {
1258 fn default() -> Self {
1259 Self::new()
1260 }
1261}
1262
1263impl PickerSourceGenerator for RecentFilesSource {
1264 fn spec(&self) -> &PickerSourceSpec {
1265 &self.spec
1266 }
1267
1268 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1269 if ctx.recent_files.is_empty() {
1270 return Err("no recent files".into());
1271 }
1272 let pairs = ctx
1273 .recent_files
1274 .iter()
1275 .map(|p| {
1276 let display = p.display().to_string();
1277 let mut cand = RawCandidate::plain(display, CandidateKind::Plain);
1278 // MP.3: same eza-style perm/size/mtime marginalia as the
1279 // file picker. A path that fails to stat (since-deleted MRU
1280 // entry) emits no metadata cells → blank, no error.
1281 if let Ok(meta) = std::fs::metadata(p) {
1282 cand.annotations = metadata_annotations(&meta);
1283 }
1284 // Slice 7b.2: typed accept payload.
1285 cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::OpenFile {
1286 path: p.clone(),
1287 }));
1288 (cand, RoutingPayload::OpenFile { path: p.clone() })
1289 })
1290 .collect();
1291 Ok(PickerInitResult::Inline(pairs))
1292 }
1293
1294 fn accept(
1295 &self,
1296 _ctx: &PickerContext<'_>,
1297 routing: &RoutingPayload,
1298 ) -> SourceResult<PickerAcceptOutcome> {
1299 match routing {
1300 RoutingPayload::OpenFile { path } => {
1301 Ok(PickerAcceptOutcome::OpenFile { path: path.clone() })
1302 }
1303 other => Err(format!("recent: unexpected routing payload {other:?}")),
1304 }
1305 }
1306}
1307
1308/// `:picker buffers`. Walks `ctx.buffers` and emits one row
1309/// per registered buffer, with `(current)` marginalia on the
1310/// active one. Active buffer floats to the bottom of the
1311/// list so the alternate-buffer convention (`<C-^>`-style)
1312/// keeps working: the initial selection lands on the
1313/// alternate, not on the buffer the user already sees.
1314pub struct BuffersSource {
1315 pub spec: PickerSourceSpec,
1316}
1317
1318impl BuffersSource {
1319 pub fn new() -> Self {
1320 Self {
1321 spec: PickerSourceSpec::no_args(
1322 "buffers",
1323 "Live buffer switcher. Walks every entry in BufferRegistry; accept activates the chosen buffer.",
1324 ),
1325 }
1326 }
1327}
1328
1329impl Default for BuffersSource {
1330 fn default() -> Self {
1331 Self::new()
1332 }
1333}
1334
1335impl PickerSourceGenerator for BuffersSource {
1336 fn spec(&self) -> &PickerSourceSpec {
1337 &self.spec
1338 }
1339
1340 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1341 let active = ctx.active_buffer.buffer_id;
1342 // Float the active buffer to the bottom of the list
1343 // so the initial selection lands on the alternate.
1344 let mut entries: Vec<&crate::BufferEntry> = ctx.buffers.iter().collect();
1345 entries.sort_by_key(|e| (e.id == active, e.id));
1346 let pairs = entries
1347 .into_iter()
1348 .map(|e| {
1349 let path_display = e
1350 .path
1351 .as_ref()
1352 .map(|p| p.display().to_string())
1353 .unwrap_or_else(|| e.title.clone());
1354 // MP.3: the path is the matchable `display`; buffer-id,
1355 // dirty/active status, and kind become typed marginalia
1356 // (no inline `#id`/`[+]`/`(current)` markers). Column order
1357 // is fixed by `category_order` (kind → status → buffer-id).
1358 let mut cand = RawCandidate::plain(path_display, CandidateKind::Buffer);
1359 let mut annotations = vec![
1360 Annotation::Kind(e.kind_label.clone().into()),
1361 Annotation::Styled {
1362 category: "buffer-id".into(),
1363 segments: vec![txt_seg(format!("#{}", e.id), SLOT_BUFFER_ID)],
1364 },
1365 ];
1366 let status = status_segments(e.dirty, e.id == active);
1367 if !status.is_empty() {
1368 annotations.push(Annotation::Styled {
1369 category: "status".into(),
1370 segments: status,
1371 });
1372 }
1373 cand.annotations = annotations;
1374 // Slice 7b.1: typed accept payload on the
1375 // candidate. Parallel to the existing
1376 // RoutingPayload (still emitted for the picker's
1377 // routing_meta lookup) — slice 7d's registry
1378 // cutover drops the parallel routing vec once
1379 // the host routes accept through
1380 // DefaultAcceptHandler.
1381 cand.accept_action =
1382 Some(Box::new(lattice_completion::AcceptAction::SwitchBuffer {
1383 id: lattice_core::BufferId(e.id),
1384 }));
1385 (cand, RoutingPayload::Buffer { id: e.id })
1386 })
1387 .collect();
1388 Ok(PickerInitResult::Inline(pairs))
1389 }
1390
1391 fn accept(
1392 &self,
1393 _ctx: &PickerContext<'_>,
1394 routing: &RoutingPayload,
1395 ) -> SourceResult<PickerAcceptOutcome> {
1396 match routing {
1397 RoutingPayload::Buffer { id } => {
1398 Ok(PickerAcceptOutcome::SwitchBuffer { buffer_id: *id })
1399 }
1400 other => Err(format!("buffers: unexpected routing payload {other:?}")),
1401 }
1402 }
1403}
1404
1405/// `:picker lines`. Walks the active buffer's rope and emits
1406/// one row per logical line, displayed as `<lineno>: <text>`.
1407/// Accept jumps the cursor to that line via
1408/// `RoutingPayload::JumpInBuffer`. The buffer_id is captured
1409/// at picker-open so a sibling hover-preview can't accidentally
1410/// redirect the jump.
1411pub struct LinesSource {
1412 pub spec: PickerSourceSpec,
1413}
1414
1415impl LinesSource {
1416 pub fn new() -> Self {
1417 Self {
1418 spec: PickerSourceSpec::no_args(
1419 "lines",
1420 "Active buffer's lines. Type to filter; `<CR>` jumps to that line.",
1421 ),
1422 }
1423 }
1424}
1425
1426impl Default for LinesSource {
1427 fn default() -> Self {
1428 Self::new()
1429 }
1430}
1431
1432impl PickerSourceGenerator for LinesSource {
1433 fn spec(&self) -> &PickerSourceSpec {
1434 &self.spec
1435 }
1436
1437 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1438 let buffer = ctx.active_buffer.buffer;
1439 let buffer_id = ctx.active_buffer.buffer_id;
1440 // CV.3: content space. This used to hand-roll the
1441 // trailing-empty-line correction inline — the accessor is that
1442 // correction, named.
1443 let line_count = buffer.content_line_count();
1444 if line_count == 0 {
1445 return Err("lines: empty buffer".into());
1446 }
1447 let last = line_count - 1;
1448 let mut pairs = Vec::with_capacity(last as usize + 1);
1449 for line in 0..=last {
1450 let text = buffer.line(line).unwrap_or_default();
1451 let text = text.trim_end_matches('\n');
1452 // MP.4: the line text is the matchable `display` (and the
1453 // future PH.2 syntax-highlight target); the line number moves
1454 // to a `location` marginalia cell.
1455 let mut cand = RawCandidate::plain(text.to_string(), CandidateKind::Plain);
1456 cand.annotations = vec![location_annotation(None, line + 1, None)];
1457 // PH.2: syntax-color the line preview. The host pre-collected
1458 // per-line spans (line-relative byte offsets); the line text
1459 // *is* the `display`, so the spans map 1:1. Clip to the
1460 // trimmed display length (the trailing `\n` was stripped);
1461 // the renderer additionally guards char boundaries. No spans
1462 // for this line → plain preview.
1463 cand.display_spans = display_spans_for_line(
1464 &ctx.active_buffer.syntax_highlights,
1465 line,
1466 cand.display.len(),
1467 );
1468 // Slice 7b.4: typed accept payload.
1469 cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
1470 buffer_id: lattice_core::BufferId(buffer_id),
1471 line,
1472 col: 0,
1473 }));
1474 pairs.push((
1475 cand,
1476 RoutingPayload::JumpInBuffer {
1477 buffer_id,
1478 line,
1479 col: 0,
1480 },
1481 ));
1482 }
1483 Ok(PickerInitResult::Inline(pairs))
1484 }
1485
1486 fn accept(
1487 &self,
1488 _ctx: &PickerContext<'_>,
1489 routing: &RoutingPayload,
1490 ) -> SourceResult<PickerAcceptOutcome> {
1491 match routing {
1492 RoutingPayload::JumpInBuffer {
1493 buffer_id,
1494 line,
1495 col,
1496 } => Ok(PickerAcceptOutcome::JumpInBuffer {
1497 buffer_id: *buffer_id,
1498 line: *line,
1499 col: *col,
1500 }),
1501 other => Err(format!("lines: unexpected routing payload {other:?}")),
1502 }
1503 }
1504}
1505
1506/// `:picker jumps`. Walks `ctx.position_history` (unified
1507/// jump-list + mark-ring per §5.1.1) and emits one row per
1508/// entry, newest first. Accept emits `JumpInBuffer` so the
1509/// host's apply translator handles "activate buffer +
1510/// position cursor" uniformly. MRU is correctly absent for
1511/// these rows -- `routing_identity` returns `None` for
1512/// `JumpInBuffer` because coordinates drift.
1513pub struct JumpsSource {
1514 pub spec: PickerSourceSpec,
1515}
1516
1517impl JumpsSource {
1518 pub fn new() -> Self {
1519 Self {
1520 spec: PickerSourceSpec::no_args(
1521 "jumps",
1522 "Position-history ring (unified jump list + mark ring). Newest first; `<CR>` jumps to that entry.",
1523 ),
1524 }
1525 }
1526}
1527
1528impl Default for JumpsSource {
1529 fn default() -> Self {
1530 Self::new()
1531 }
1532}
1533
1534impl PickerSourceGenerator for JumpsSource {
1535 fn spec(&self) -> &PickerSourceSpec {
1536 &self.spec
1537 }
1538
1539 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1540 if ctx.position_history.is_empty() {
1541 return Err("jumps: position history is empty".into());
1542 }
1543 // Walk newest-first. The ring stores oldest-first
1544 // (push appends to the end) so reverse iteration is
1545 // the user-facing default.
1546 let pairs = ctx
1547 .position_history
1548 .iter()
1549 .rev()
1550 .map(|entry| {
1551 let source_tag = match entry.source {
1552 crate::PositionSource::AutoJump => "auto".to_string(),
1553 crate::PositionSource::ExplicitMark => "mark".to_string(),
1554 crate::PositionSource::PluginPush => "plugin".to_string(),
1555 crate::PositionSource::NamedMark(c) => format!("'{c}"),
1556 };
1557 // Resolve buffer_id to a display label via the
1558 // buffers snapshot; fall back to the raw id when
1559 // the buffer is no longer in the registry.
1560 let buf_label = ctx
1561 .buffers
1562 .iter()
1563 .find(|b| b.id == entry.buffer_id)
1564 .map(|b| {
1565 b.path
1566 .as_ref()
1567 .map(|p| p.display().to_string())
1568 .unwrap_or_else(|| b.title.clone())
1569 })
1570 .unwrap_or_else(|| format!("#{}", entry.buffer_id));
1571 // MP.4: buffer label is the matchable `display`; the
1572 // provenance tag becomes a `Source` cell and the
1573 // coordinates a `location` cell (line:col, no path — the
1574 // path/label is already the display).
1575 let mut cand = RawCandidate::plain(buf_label, CandidateKind::Plain);
1576 cand.annotations = vec![
1577 Annotation::Source(source_tag.into()),
1578 location_annotation(None, entry.line + 1, Some(entry.col + 1)),
1579 ];
1580 // Slice 7b.4: typed accept payload.
1581 cand.accept_action =
1582 Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
1583 buffer_id: lattice_core::BufferId(entry.buffer_id),
1584 line: entry.line,
1585 col: entry.col,
1586 }));
1587 (
1588 cand,
1589 RoutingPayload::JumpInBuffer {
1590 buffer_id: entry.buffer_id,
1591 line: entry.line,
1592 col: entry.col,
1593 },
1594 )
1595 })
1596 .collect();
1597 Ok(PickerInitResult::Inline(pairs))
1598 }
1599
1600 fn accept(
1601 &self,
1602 _ctx: &PickerContext<'_>,
1603 routing: &RoutingPayload,
1604 ) -> SourceResult<PickerAcceptOutcome> {
1605 match routing {
1606 RoutingPayload::JumpInBuffer {
1607 buffer_id,
1608 line,
1609 col,
1610 } => Ok(PickerAcceptOutcome::JumpInBuffer {
1611 buffer_id: *buffer_id,
1612 line: *line,
1613 col: *col,
1614 }),
1615 other => Err(format!("jumps: unexpected routing payload {other:?}")),
1616 }
1617 }
1618}
1619
1620/// `:picker commands`. Walks the App's `CommandRegistry`
1621/// and emits one row per registered ex-command (motions,
1622/// operators, etc. are not user-invocable through this
1623/// surface and stay out). Captures an `Arc<CommandRegistry>`
1624/// at construction time -- the registry doesn't live on
1625/// `PickerContext` because it's static App-wide state, not
1626/// per-invocation snapshot data.
1627pub struct CommandsSource {
1628 pub spec: PickerSourceSpec,
1629 /// B3b: the `ArcSwap` handle (not a boot snapshot) so the palette
1630 /// enumerates commands a plugin registered at runtime — `init`
1631 /// `.load()`s it per open, mirroring the `reverse` cache below.
1632 pub registry: CommandRegistryHandle,
1633 /// MP.2b: name → first-bound-chord reverse lookup. Captured
1634 /// at construction like `registry` (both are static
1635 /// App-wide facades, not per-open snapshot state — see the
1636 /// `PickerContext` module doc on why feature facades live
1637 /// here, not on the context). Each call reads the keymap's
1638 /// live `ArcSwap` reverse cache, so a `:map` / `:unmap`
1639 /// between picker opens is reflected without rebuilding the
1640 /// source.
1641 pub reverse: Arc<dyn KeymapReverseLookup>,
1642}
1643
1644impl CommandsSource {
1645 pub fn new(registry: CommandRegistryHandle, reverse: Arc<dyn KeymapReverseLookup>) -> Self {
1646 Self {
1647 spec: PickerSourceSpec::no_args(
1648 "commands",
1649 "Ex-command palette. Walks the CommandRegistry; `<CR>` invokes the chosen command.",
1650 ),
1651 registry,
1652 reverse,
1653 }
1654 }
1655}
1656
1657impl PickerSourceGenerator for CommandsSource {
1658 fn spec(&self) -> &PickerSourceSpec {
1659 &self.spec
1660 }
1661
1662 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1663 // Walk registry names, keep ex-commands, project to a
1664 // row carrying every marginalia column. Emacs
1665 // `marginalia.el`-style: name | args-hint | doc |
1666 // latency-tag, all right-padded to align across rows.
1667 // Mode-toggle ex-commands like `buffer-words-mode`
1668 // register without the `ex:` prefix; the projection
1669 // handles both.
1670 struct Row {
1671 user_facing: String,
1672 canonical: String,
1673 args_hint: String,
1674 doc: String,
1675 latency: LatencyClass,
1676 }
1677 // B3b: wait-free snapshot for this open; a runtime-registered
1678 // plugin command is enumerated on the next palette open.
1679 let registry = self.registry.load();
1680 let mut rows: Vec<Row> = registry
1681 .names()
1682 .filter_map(|canonical| {
1683 let spec = registry.lookup_by_name(canonical)?;
1684 if !matches!(spec.kind, CommandKind::ExCommand) {
1685 return None;
1686 }
1687 let user_facing = canonical
1688 .strip_prefix("ex:")
1689 .unwrap_or(canonical)
1690 .to_string();
1691 let args_hint = format_args_hint(&spec.args_schema);
1692 let one_line_doc: String = spec
1693 .doc
1694 .lines()
1695 .next()
1696 .unwrap_or("")
1697 .chars()
1698 .take(80)
1699 .collect();
1700 Some(Row {
1701 user_facing,
1702 canonical: canonical.to_string(),
1703 args_hint,
1704 doc: one_line_doc,
1705 latency: spec.latency_class,
1706 })
1707 })
1708 .collect();
1709 // Sort by user-facing name so the popup matches the
1710 // alphabetic order users see.
1711 rows.sort_by(|a, b| a.user_facing.cmp(&b.user_facing));
1712 if rows.is_empty() {
1713 return Err("commands: no ex-commands registered".into());
1714 }
1715 // MP.2: the command name is the matchable `display`; args-hint,
1716 // doc, and latency become typed marginalia (`AnnotationColumns`
1717 // owns alignment — no hand-padding). Column order is fixed by
1718 // `category_order` (args → doc → latency).
1719 let pairs = rows
1720 .into_iter()
1721 .map(|row| {
1722 let mut cand = RawCandidate::plain(row.user_facing.clone(), CandidateKind::Plain);
1723 let mut annotations: Vec<Annotation> = Vec::with_capacity(4);
1724 // MP.2b: the keybinding column. The reverse cache
1725 // stores one binding's chord *sequence* per command
1726 // (first-binding-wins), so a non-empty result is the
1727 // chord to surface — `marginalia.md` §6. Commands with
1728 // no Normal-mode chord push nothing (blank cell, no
1729 // zero-width span). Rendered leftmost regardless of
1730 // push order (category rank 0).
1731 //
1732 // MARG.3 (2026-07-15): use `chords_with_source` to
1733 // get mode provenance. Chords whose source
1734 // minor/major mode is not currently active are
1735 // filtered out. When a chord comes from an active
1736 // mode, a `Source` annotation carries the mode name.
1737 let chords_with_source = self.reverse.chords_with_source(&row.canonical);
1738 let visible_chords: Vec<KeyChord> = chords_with_source
1739 .iter()
1740 .filter(|(_, source)| match source {
1741 KeybindingSource::AlwaysOn => true,
1742 KeybindingSource::Mode(mode_name) => ctx.active_modes.contains(mode_name),
1743 })
1744 .map(|(chord, _)| *chord)
1745 .collect();
1746 if !visible_chords.is_empty() {
1747 annotations.push(Annotation::Keybinding(visible_chords));
1748 // Show the source mode label for the first
1749 // active chord. Builtin/User/Buffer chords
1750 // omit the source column (it's implied).
1751 let source_label = chords_with_source
1752 .iter()
1753 .find(|(_, source)| match source {
1754 KeybindingSource::Mode(name) => ctx.active_modes.contains(name),
1755 _ => false,
1756 })
1757 .map(|(_, source)| match source {
1758 KeybindingSource::Mode(name) => name.clone(),
1759 _ => unreachable!(),
1760 });
1761 if let Some(label) = source_label {
1762 annotations.push(Annotation::Source(label));
1763 }
1764 }
1765 if !row.args_hint.is_empty() {
1766 annotations.push(Annotation::Styled {
1767 category: "args".into(),
1768 segments: vec![txt_seg(row.args_hint, SLOT_ARGS)],
1769 });
1770 }
1771 if !row.doc.is_empty() {
1772 annotations.push(Annotation::DocSnippet(row.doc.into()));
1773 }
1774 annotations.push(Annotation::Styled {
1775 category: "latency".into(),
1776 segments: vec![latency_segment(row.latency)],
1777 });
1778 cand.annotations = annotations;
1779 // Slice 7b.3: typed accept payload.
1780 cand.accept_action =
1781 Some(Box::new(lattice_completion::AcceptAction::InvokeCommand {
1782 id: row.canonical.clone(),
1783 args: Args::None,
1784 }));
1785 (
1786 cand,
1787 RoutingPayload::InvokeCommand {
1788 id: row.canonical,
1789 args: Args::None,
1790 },
1791 )
1792 })
1793 .collect();
1794 Ok(PickerInitResult::Inline(pairs))
1795 }
1796
1797 fn accept(
1798 &self,
1799 _ctx: &PickerContext<'_>,
1800 routing: &RoutingPayload,
1801 ) -> SourceResult<PickerAcceptOutcome> {
1802 match routing {
1803 RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1804 id: id.clone(),
1805 args: args.clone(),
1806 }),
1807 other => Err(format!("commands: unexpected routing payload {other:?}")),
1808 }
1809 }
1810}
1811
1812/// `:picker history` — also reached via the `q:` Normal chord and
1813/// the `:history` ex-command (MB.3). Walks `ctx.command_history`
1814/// (the App's command-line history ring, stored oldest-first) and
1815/// emits one row per past command, **newest first**. `<CR>` loads
1816/// the chosen command into the editable `:` line via
1817/// [`RoutingPayload::LoadCommandLine`] — it does **not** execute;
1818/// the user tweaks (or `<C-x><C-e>` expands) then `<CR>`s. The
1819/// modern replacement for vim's command-line *window*: fuzzy-filter
1820/// past commands instead of scrolling a scratch buffer
1821/// (`docs/dev/architecture/rich-minibuffer.md` §4).
1822///
1823/// The history ring already collapses *consecutive* duplicates at
1824/// push time, so no dedup here; non-adjacent repeats (`:w` … `:w`)
1825/// stay as distinct rows, matching vim's `:history`.
1826pub struct CommandHistorySource {
1827 pub spec: PickerSourceSpec,
1828}
1829
1830impl CommandHistorySource {
1831 pub fn new() -> Self {
1832 Self {
1833 spec: PickerSourceSpec::no_args(
1834 "history",
1835 "Command-line history. `<CR>` loads the chosen command into the `:` line (does not execute).",
1836 ),
1837 }
1838 }
1839}
1840
1841impl Default for CommandHistorySource {
1842 fn default() -> Self {
1843 Self::new()
1844 }
1845}
1846
1847impl PickerSourceGenerator for CommandHistorySource {
1848 fn spec(&self) -> &PickerSourceSpec {
1849 &self.spec
1850 }
1851
1852 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1853 if ctx.command_history.is_empty() {
1854 return Err("history: no command-line history yet".into());
1855 }
1856 // Newest-first: the ring is stored oldest-first, so walk it
1857 // reversed. Empty-query order is insertion order, which after
1858 // the reverse floats the most-recent command to the top.
1859 let pairs = ctx
1860 .command_history
1861 .iter()
1862 .rev()
1863 .map(|entry| {
1864 let cand = RawCandidate::plain(entry.clone(), CandidateKind::Plain);
1865 (
1866 cand,
1867 RoutingPayload::LoadCommandLine {
1868 text: entry.clone(),
1869 },
1870 )
1871 })
1872 .collect();
1873 Ok(PickerInitResult::Inline(pairs))
1874 }
1875
1876 fn accept(
1877 &self,
1878 _ctx: &PickerContext<'_>,
1879 routing: &RoutingPayload,
1880 ) -> SourceResult<PickerAcceptOutcome> {
1881 match routing {
1882 RoutingPayload::LoadCommandLine { text } => {
1883 Ok(PickerAcceptOutcome::LoadCommandLine { text: text.clone() })
1884 }
1885 other => Err(format!("history: unexpected routing payload {other:?}")),
1886 }
1887 }
1888}
1889
1890/// PBH.5: `:picker pane-buffer-history` — also reached via
1891/// `:history pane-buffers`. Walks the ACTIVE pane's buffer trail
1892/// (`ctx.pane_buffer_history`, oldest-first as stored) and emits one
1893/// row per stop, **newest first**, marking the entry the walk cursor
1894/// currently sits on.
1895///
1896/// `<CR>` **moves the walk cursor** to the chosen stop rather than
1897/// recording a new visit — the picker is random access over the trail
1898/// that `<C-6>` / `<C-7>` step through, not a fresh navigation. Pushing
1899/// instead would append a duplicate and make forward unreachable,
1900/// exactly as an unsuppressed walk would.
1901///
1902/// Rows route by trail **index**, not buffer id: the same buffer can
1903/// appear at several stops, and picking the third must land on the
1904/// third.
1905pub struct PaneBufferHistorySource {
1906 pub spec: PickerSourceSpec,
1907}
1908
1909impl PaneBufferHistorySource {
1910 pub fn new() -> Self {
1911 Self {
1912 spec: PickerSourceSpec::no_args(
1913 "pane-buffer-history",
1914 "This pane's buffer history. `<CR>` walks to the chosen entry (does not record a new visit).",
1915 ),
1916 }
1917 }
1918}
1919
1920impl Default for PaneBufferHistorySource {
1921 fn default() -> Self {
1922 Self::new()
1923 }
1924}
1925
1926impl PickerSourceGenerator for PaneBufferHistorySource {
1927 fn spec(&self) -> &PickerSourceSpec {
1928 &self.spec
1929 }
1930
1931 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
1932 if ctx.pane_buffer_history.is_empty() {
1933 return Err("pane-buffers: this pane has no buffer history yet".into());
1934 }
1935 // Newest-first: the trail is stored oldest-first, so walk it
1936 // reversed. Matches the command/search history sources, and puts
1937 // the stop you most recently left on top.
1938 let pairs = ctx
1939 .pane_buffer_history
1940 .iter()
1941 .rev()
1942 .map(|row| {
1943 let marker = if row.is_current { "*" } else { " " };
1944 let text = format!("{marker} {}:{}", row.label, row.line);
1945 let cand = RawCandidate::plain(text, CandidateKind::Buffer);
1946 (cand, RoutingPayload::PaneHistoryEntry { index: row.index })
1947 })
1948 .collect();
1949 Ok(PickerInitResult::Inline(pairs))
1950 }
1951
1952 fn accept(
1953 &self,
1954 _ctx: &PickerContext<'_>,
1955 routing: &RoutingPayload,
1956 ) -> SourceResult<PickerAcceptOutcome> {
1957 match routing {
1958 // A walk, not a visit: `SwitchBuffer` would activate the
1959 // buffer through the generic path and record a new visit.
1960 RoutingPayload::PaneHistoryEntry { index } => {
1961 Ok(PickerAcceptOutcome::WalkPaneHistory { index: *index })
1962 }
1963 other => Err(format!(
1964 "pane-buffer-history: unexpected routing payload {other:?}"
1965 )),
1966 }
1967 }
1968}
1969
1970/// MB.5: `:picker search-history` — also reached via the `q/` / `q?`
1971/// Normal chords and `:history search`. Walks `ctx.search_history`
1972/// (the App's search-line history ring, stored oldest-first) and
1973/// emits one row per past search term, **newest first**. `<CR>` loads
1974/// the chosen term into the editable `/` line via
1975/// [`RoutingPayload::LoadSearchLine`] — it does **not** execute.
1976pub struct SearchHistorySource {
1977 pub spec: PickerSourceSpec,
1978}
1979
1980impl SearchHistorySource {
1981 pub fn new() -> Self {
1982 Self {
1983 spec: PickerSourceSpec::no_args(
1984 "search-history",
1985 "Search-line history. `<CR>` loads the chosen term into the `/` search line (does not execute).",
1986 ),
1987 }
1988 }
1989}
1990
1991impl Default for SearchHistorySource {
1992 fn default() -> Self {
1993 Self::new()
1994 }
1995}
1996
1997impl PickerSourceGenerator for SearchHistorySource {
1998 fn spec(&self) -> &PickerSourceSpec {
1999 &self.spec
2000 }
2001
2002 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2003 if ctx.search_history.is_empty() {
2004 return Err("history: no search-line history yet".into());
2005 }
2006 let pairs = ctx
2007 .search_history
2008 .iter()
2009 .rev()
2010 .map(|entry| {
2011 let cand = RawCandidate::plain((*entry).clone(), CandidateKind::Plain);
2012 (
2013 cand,
2014 RoutingPayload::LoadSearchLine {
2015 text: (*entry).clone(),
2016 },
2017 )
2018 })
2019 .collect();
2020 Ok(PickerInitResult::Inline(pairs))
2021 }
2022
2023 fn accept(
2024 &self,
2025 _ctx: &PickerContext<'_>,
2026 routing: &RoutingPayload,
2027 ) -> SourceResult<PickerAcceptOutcome> {
2028 match routing {
2029 RoutingPayload::LoadSearchLine { text } => {
2030 Ok(PickerAcceptOutcome::LoadSearchLine { text: text.clone() })
2031 }
2032 other => Err(format!(
2033 "search-history: unexpected routing payload {other:?}"
2034 )),
2035 }
2036 }
2037}
2038
2039/// `:picker registers`. Walks `ctx.registers` (`(name,
2040/// preview)` pairs already prepared by the host's
2041/// `build_picker_context`) and emits one row per register.
2042/// Accept emits `PasteRegister { name }`; the host routes
2043/// through `do_paste` with the chosen register pre-selected.
2044pub struct RegistersSource {
2045 pub spec: PickerSourceSpec,
2046}
2047
2048impl RegistersSource {
2049 pub fn new() -> Self {
2050 Self {
2051 spec: PickerSourceSpec::no_args(
2052 "registers",
2053 "Vim-style registers (unnamed, numbered, named). `<CR>` pastes the chosen register at the cursor.",
2054 ),
2055 }
2056 }
2057}
2058
2059impl Default for RegistersSource {
2060 fn default() -> Self {
2061 Self::new()
2062 }
2063}
2064
2065impl PickerSourceGenerator for RegistersSource {
2066 fn spec(&self) -> &PickerSourceSpec {
2067 &self.spec
2068 }
2069
2070 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2071 if ctx.registers.is_empty() {
2072 return Err("registers: no registers set".into());
2073 }
2074 let pairs = ctx
2075 .registers
2076 .iter()
2077 .filter_map(|(name, preview)| {
2078 // Pick the first char of the name as the routing
2079 // key. Names are always one char today; future
2080 // multi-char keys (vim doesn't have any) would
2081 // need a richer routing variant.
2082 let ch = name.chars().next()?;
2083 // MP.4/§9: the register contents are the matchable
2084 // `display`; the register name (`"a`) is a `register`
2085 // marginalia cell.
2086 // `ctx.registers` carries FULL contents now, so the
2087 // display truncation happens here rather than upstream.
2088 let mut cand = RawCandidate::plain(one_line_preview(preview), CandidateKind::Plain);
2089 cand.annotations = vec![Annotation::Styled {
2090 category: "register".into(),
2091 segments: vec![txt_seg(format!("\"{name}"), SLOT_REGISTER)],
2092 }];
2093 // Slice 7b.5: typed accept payload.
2094 cand.accept_action =
2095 Some(Box::new(lattice_completion::AcceptAction::PasteRegister {
2096 name: ch,
2097 }));
2098 Some((cand, RoutingPayload::PasteRegister { name: ch }))
2099 })
2100 .collect();
2101 Ok(PickerInitResult::Inline(pairs))
2102 }
2103
2104 fn accept(
2105 &self,
2106 _ctx: &PickerContext<'_>,
2107 routing: &RoutingPayload,
2108 ) -> SourceResult<PickerAcceptOutcome> {
2109 match routing {
2110 RoutingPayload::PasteRegister { name } => {
2111 Ok(PickerAcceptOutcome::PasteRegister { name: *name })
2112 }
2113 other => Err(format!("registers: unexpected routing payload {other:?}")),
2114 }
2115 }
2116}
2117
2118/// `:picker marks`. Walks `ctx.marks` (sorted by name in
2119/// `build_picker_context`) and emits one row per set mark.
2120/// Accept emits `JumpToMark { name }` which the host
2121/// resolves through `do_jump_mark` -- same path the `` ` ``
2122/// motion uses, so cursor placement + position-history push
2123/// match keyboard-driven behavior. MRU will key on
2124/// `mark:<name>` automatically when slice 14 lands.
2125pub struct MarksSource {
2126 pub spec: PickerSourceSpec,
2127}
2128
2129impl MarksSource {
2130 pub fn new() -> Self {
2131 Self {
2132 spec: PickerSourceSpec::no_args(
2133 "marks",
2134 "Vim-style marks. `<CR>` jumps to the mark via the same path as `` ` ``.",
2135 ),
2136 }
2137 }
2138}
2139
2140impl Default for MarksSource {
2141 fn default() -> Self {
2142 Self::new()
2143 }
2144}
2145
2146impl PickerSourceGenerator for MarksSource {
2147 fn spec(&self) -> &PickerSourceSpec {
2148 &self.spec
2149 }
2150
2151 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2152 if ctx.marks.is_empty() {
2153 return Err("marks: no marks set".into());
2154 }
2155 let pairs = ctx
2156 .marks
2157 .iter()
2158 .map(|(name, pos)| {
2159 // MP.4: the mark name (`'a`) is the matchable `display`;
2160 // line:col becomes a `location` cell.
2161 let mut cand = RawCandidate::plain(format!("'{name}"), CandidateKind::Plain);
2162 cand.annotations =
2163 vec![location_annotation(None, pos.line + 1, Some(pos.byte + 1))];
2164 // Slice 7b.5: typed accept payload.
2165 cand.accept_action = Some(Box::new(lattice_completion::AcceptAction::JumpToMark {
2166 name: *name,
2167 }));
2168 (cand, RoutingPayload::JumpToMark { name: *name })
2169 })
2170 .collect();
2171 Ok(PickerInitResult::Inline(pairs))
2172 }
2173
2174 fn accept(
2175 &self,
2176 _ctx: &PickerContext<'_>,
2177 routing: &RoutingPayload,
2178 ) -> SourceResult<PickerAcceptOutcome> {
2179 match routing {
2180 RoutingPayload::JumpToMark { name } => {
2181 Ok(PickerAcceptOutcome::JumpToMark { name: *name })
2182 }
2183 other => Err(format!("marks: unexpected routing payload {other:?}")),
2184 }
2185 }
2186}
2187
2188/// `:picker grep <pattern>`. Shells out to a configurable
2189/// backend (`rg`, `ag`, `grep`, or `auto`-detected at
2190/// invocation time) and walks its output line-by-line.
2191///
2192/// Sync subprocess for v1 (matches Files / Recent design --
2193/// users invoke explicitly; brief wait is acceptable). The
2194/// `:picker grep` ergonomic equivalent of vertico-buffer
2195/// live-grep with prescient ranking ships once the async
2196/// init seat path lands; until then this is the simplest
2197/// path that respects the configurable-backend requirement.
2198///
2199/// Captures `Arc<ConfigRegistry>` at construction so the
2200/// backend choice is read at every invocation (lets the user
2201/// `:set picker.grep.backend = "ag"` mid-session and see
2202/// it take effect on the next `:picker grep`).
2203/// PH.3: off-thread syntax highlighter for grep preview lines.
2204/// `lattice-picker` deliberately has NO `lattice-syntax`
2205/// dependency (the structural off-thread guarantee — a source
2206/// physically cannot parse on the render thread). The host
2207/// injects a concrete impl that selects a grammar by the hit's
2208/// file extension and highlights the single preview line. Runs
2209/// on the grep blocking task; returns display-relative
2210/// `DisplaySpan`s, empty when no grammar matches (→ plain
2211/// preview). See `docs/dev/architecture/picker-preview-highlight.md` §7.
2212pub trait GrepPreviewHighlighter: Send + Sync {
2213 /// Highlight `line` as source for the file at `path`. `line`
2214 /// is the exact text shown as the candidate `display` (already
2215 /// trimmed), so returned spans are display-relative and need
2216 /// no offset. Empty result ⇒ plain preview.
2217 fn highlight_line(
2218 &self,
2219 path: &std::path::Path,
2220 line: &str,
2221 ) -> Vec<lattice_completion::DisplaySpan>;
2222}
2223
2224pub struct GrepSource {
2225 pub spec: PickerSourceSpec,
2226 pub config: Arc<ConfigRegistry>,
2227 /// PH.3: optional preview highlighter, captured at
2228 /// construction like `config`. `None` ⇒ plain previews
2229 /// (e.g. tests, or a host that doesn't wire syntax).
2230 pub highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2231}
2232
2233impl GrepSource {
2234 pub fn new(
2235 config: Arc<ConfigRegistry>,
2236 highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2237 ) -> Self {
2238 use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
2239 Self {
2240 spec: PickerSourceSpec {
2241 create_label: None,
2242 delete_command: None,
2243 help_topic: None,
2244 id: "grep".into(),
2245 // The search is run WITH the root as its cwd, so the root is
2246 // half of what a hit means.
2247 rooted: true,
2248 doc: "Live recursive text search via the configured backend (`rg`/`ag`/`grep`). Re-runs as you type; `<CR>` jumps to the chosen hit.".into(),
2249 args_hint: "[pattern]".into(),
2250 args_schema: vec![ArgSpec {
2251 name: "pattern".into(),
2252 kind: ArgKind::String,
2253 doc: "Optional initial pattern. When given, seeds the picker prompt; without it, picker opens empty and runs the first grep on the first keystroke.".into(),
2254 prompt: "pattern:".into(),
2255 default: ArgDefault::None,
2256 completion: None,
2257 picker: None,
2258 }],
2259 // Slice 3: live source. Picker bypasses fuzzy
2260 // refilter (`run_grep` IS the filter); host
2261 // calls `on_query_changed` on each debounced
2262 // keystroke.
2263 live: true,
2264 },
2265 config,
2266 highlighter,
2267 }
2268 }
2269
2270 /// Resolve backend choice + max-hits from the config. Shared
2271 /// by `init` and `on_query_changed` so both routes honour
2272 /// the same `:set picker.grep.*` options.
2273 fn resolve_settings(&self) -> SourceResult<(String, usize)> {
2274 let backend_choice = self
2275 .config
2276 .get_typed::<lattice_config::core_options::PickerGrepBackend>()
2277 .map(|s| (*s).clone())
2278 .unwrap_or_else(|| "auto".to_string());
2279 let max_hits = self
2280 .config
2281 .get_typed::<lattice_config::core_options::PickerGrepMaxHits>()
2282 .map(|n| *n as usize)
2283 .unwrap_or(2000)
2284 .max(1);
2285 let resolved = resolve_grep_backend(&backend_choice)?;
2286 Ok((resolved, max_hits))
2287 }
2288
2289 /// Build a `CandidateFuture` that runs `run_grep` on
2290 /// tokio's blocking pool. Uses `spawn_blocking` because
2291 /// `run_grep` shells out via the std-sync `Command::output`
2292 /// API; running it on the async runtime's worker pool would
2293 /// pin a worker for the duration of the grep. The blocking
2294 /// pool is the right fit -- it's sized for exactly this
2295 /// kind of task.
2296 fn spawn_grep(
2297 binary: String,
2298 pattern: String,
2299 root: std::path::PathBuf,
2300 max_hits: usize,
2301 highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2302 ) -> crate::CandidateFuture {
2303 Box::pin(async move {
2304 // PH.3: run BOTH the grep AND the per-hit syntax
2305 // highlighting inside the blocking closure — the
2306 // highlighting is CPU-bound (per-line tree-sitter parse)
2307 // and must not land on an async runtime worker. Off the
2308 // render thread by construction (the picker crate has no
2309 // syntax dep; the highlighter is host-injected).
2310 let join = tokio::task::spawn_blocking(move || {
2311 run_grep(&binary, &pattern, &root, max_hits)
2312 .map(|hits| hits_to_pairs(hits, highlighter.as_deref()))
2313 })
2314 .await;
2315 match join {
2316 Ok(Ok(pairs)) => Ok(pairs),
2317 Ok(Err(e)) => Err(e),
2318 Err(e) => Err(format!("grep: task panicked: {e}")),
2319 }
2320 })
2321 }
2322}
2323
2324/// Convert raw grep hits into the picker's `(RawCandidate,
2325/// RoutingPayload)` pairs. Shared by the sync init() fast
2326/// path (no initial pattern → empty pairs) and the async
2327/// future path that the live grep flow drives. Empty input
2328/// → empty output; callers don't special-case.
2329fn hits_to_pairs(
2330 hits: Vec<GrepHit>,
2331 highlighter: Option<&dyn GrepPreviewHighlighter>,
2332) -> crate::CandidateBatch {
2333 hits.into_iter()
2334 .map(|hit| {
2335 // MP.4: the matched preview text is the matchable `display`;
2336 // path:line:col becomes a `location` marginalia cell.
2337 let path_display = hit.path.display().to_string();
2338 let preview = hit.preview.trim_start().to_string();
2339 let mut cand = RawCandidate::plain(preview.clone(), CandidateKind::Plain);
2340 cand.annotations = vec![location_annotation(
2341 Some(&path_display),
2342 hit.line + 1,
2343 Some(hit.col + 1),
2344 )];
2345 // PH.3: syntax-color the preview when a highlighter is wired
2346 // and a grammar matches the file. `display` IS the trimmed
2347 // preview, so spans come back display-relative; no grammar /
2348 // no spans → plain preview. Runs in the grep blocking task.
2349 if let Some(h) = highlighter {
2350 cand.display_spans = h.highlight_line(&hit.path, &preview);
2351 }
2352 // Slice 7b.6: typed accept payload. Grep hits jump
2353 // to file:line:col — same shape as LSP references /
2354 // definitions / diagnostics → JumpToFileLocation.
2355 cand.accept_action = Some(Box::new(
2356 lattice_completion::AcceptAction::JumpToFileLocation {
2357 path: hit.path.clone(),
2358 line: hit.line,
2359 col: hit.col,
2360 },
2361 ));
2362 (
2363 cand,
2364 RoutingPayload::LspLocation {
2365 path: hit.path,
2366 line: hit.line,
2367 col: hit.col,
2368 },
2369 )
2370 })
2371 .collect()
2372}
2373
2374impl PickerSourceGenerator for GrepSource {
2375 fn spec(&self) -> &PickerSourceSpec {
2376 &self.spec
2377 }
2378
2379 /// Slice 3: optional initial pattern. With no pattern the
2380 /// picker opens empty (no grep runs); the first keystroke
2381 /// triggers the live flow through `on_query_changed`.
2382 /// With an initial pattern the grep runs immediately --
2383 /// async via the Future variant so the UI thread doesn't
2384 /// park on the first invocation either. The host seeds
2385 /// `picker.query` with the initial pattern (live-source
2386 /// convention in `App::open_picker`), so subsequent
2387 /// keystrokes extend the same query.
2388 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
2389 let pattern = args.first().map(|s| s.trim()).filter(|s| !s.is_empty());
2390 let Some(pattern) = pattern else {
2391 return Ok(PickerInitResult::Inline(Vec::new()));
2392 };
2393 let (binary, max_hits) = self.resolve_settings()?;
2394 let root = ctx.workspace_root.to_path_buf();
2395 let fut = GrepSource::spawn_grep(
2396 binary,
2397 pattern.to_string(),
2398 root,
2399 max_hits,
2400 self.highlighter.clone(),
2401 );
2402 Ok(PickerInitResult::Future(fut))
2403 }
2404
2405 fn accept(
2406 &self,
2407 _ctx: &PickerContext<'_>,
2408 routing: &RoutingPayload,
2409 ) -> SourceResult<PickerAcceptOutcome> {
2410 match routing {
2411 RoutingPayload::LspLocation { path, line, col } => {
2412 Ok(PickerAcceptOutcome::JumpToLocation {
2413 path: path.clone(),
2414 line: *line,
2415 col: *col,
2416 })
2417 }
2418 other => Err(format!("grep: unexpected routing payload {other:?}")),
2419 }
2420 }
2421
2422 /// Slice 3: live re-execution. The host's
2423 /// `drain_pending_live_picker_query` calls this every time
2424 /// the debounce expires; we trim, special-case the empty
2425 /// query (no grep, empty result -- clears the candidate
2426 /// list), and otherwise spawn the grep on the blocking
2427 /// pool. The Future variant lets the host cancel us if a
2428 /// newer keystroke fires before we finish.
2429 fn on_query_changed(
2430 &self,
2431 ctx: &PickerContext<'_>,
2432 query: &str,
2433 ) -> Option<SourceResult<PickerInitResult>> {
2434 let trimmed = query.trim();
2435 if trimmed.is_empty() {
2436 return Some(Ok(PickerInitResult::Inline(Vec::new())));
2437 }
2438 let settings = match self.resolve_settings() {
2439 Ok(s) => s,
2440 Err(e) => return Some(Err(e)),
2441 };
2442 let (binary, max_hits) = settings;
2443 let root = ctx.workspace_root.to_path_buf();
2444 let fut = GrepSource::spawn_grep(
2445 binary,
2446 trimmed.to_string(),
2447 root,
2448 max_hits,
2449 self.highlighter.clone(),
2450 );
2451 Some(Ok(PickerInitResult::Future(fut)))
2452 }
2453}
2454
2455/// One grep hit -- path + 0-based LSP-flavored line + 0-based
2456/// utf-8 byte column + the matching line's text (preview).
2457struct GrepHit {
2458 path: std::path::PathBuf,
2459 line: u32,
2460 col: u32,
2461 preview: String,
2462}
2463
2464/// Picks the grep binary from the user's `picker.grep.backend`
2465/// option. `"auto"` walks rg / ag / grep, returning the first
2466/// on PATH. Explicit names check that single binary; missing
2467/// returns `Err` so the user can re-configure.
2468fn resolve_grep_backend(choice: &str) -> SourceResult<String> {
2469 fn on_path(name: &str) -> bool {
2470 std::env::var_os("PATH")
2471 .map(|p| {
2472 std::env::split_paths(&p).any(|dir| {
2473 // On Windows the binary is `rg.exe`; `Command::new("rg")`
2474 // finds it, so the lookup must too, or no backend is
2475 // ever found and the grep picker never opens.
2476 dir.join(name).is_file()
2477 || (cfg!(windows) && dir.join(format!("{name}.exe")).is_file())
2478 })
2479 })
2480 .unwrap_or(false)
2481 }
2482 if choice == "auto" {
2483 for candidate in ["rg", "ag", "grep"] {
2484 if on_path(candidate) {
2485 return Ok(candidate.to_string());
2486 }
2487 }
2488 return Err("grep: no backend on PATH (tried rg, ag, grep). \
2489 Set `picker.grep.backend` to a binary name."
2490 .into());
2491 }
2492 if on_path(choice) {
2493 Ok(choice.to_string())
2494 } else {
2495 Err(format!(
2496 "grep: backend `{choice}` not found on PATH \
2497 (configured via `picker.grep.backend`)"
2498 ))
2499 }
2500}
2501
2502/// Run `binary <pattern> <root>` with backend-appropriate
2503/// args and parse the output. Output formats:
2504/// - rg: `path:line:col:text`
2505/// - ag: `path:line:col:text`
2506/// - grep: `path:line:text` (no column; fall back to 0)
2507fn run_grep(
2508 binary: &str,
2509 pattern: &str,
2510 root: &std::path::Path,
2511 max_hits: usize,
2512) -> SourceResult<Vec<GrepHit>> {
2513 let mut cmd = std::process::Command::new(binary);
2514 match binary {
2515 "rg" => {
2516 cmd.args(["--no-heading", "--line-number", "--column", "--color=never"]);
2517 }
2518 "ag" => {
2519 cmd.args(["--noheading", "--column", "--nocolor"]);
2520 }
2521 "grep" => {
2522 cmd.args(["-rnH"]);
2523 }
2524 _ => {
2525 // Custom backend; assume an rg-compatible flag set.
2526 cmd.args(["--line-number", "--column"]);
2527 }
2528 }
2529 cmd.arg(pattern).arg(root);
2530 let output = cmd
2531 .output()
2532 .map_err(|e| format!("grep: spawning `{binary}` failed: {e}"))?;
2533 if !output.status.success() && output.stdout.is_empty() {
2534 // Some backends (`grep`, `rg`) return non-zero on
2535 // "no hits". Only treat as error when stderr has a
2536 // real message AND stdout is empty.
2537 let stderr = String::from_utf8_lossy(&output.stderr);
2538 if !stderr.trim().is_empty() {
2539 return Err(format!("grep: `{binary}` failed: {stderr}"));
2540 }
2541 }
2542 let stdout = String::from_utf8_lossy(&output.stdout);
2543 let mut hits = Vec::new();
2544 for raw_line in stdout.lines() {
2545 if hits.len() >= max_hits {
2546 break;
2547 }
2548 if let Some(hit) = parse_grep_line(binary, raw_line) {
2549 hits.push(hit);
2550 }
2551 }
2552 Ok(hits)
2553}
2554
2555/// Parse one output line. `path:line:col:text` for rg/ag,
2556/// `path:line:text` for grep. Path may itself contain colons
2557/// (Windows drive letters, or files with `:` in name); we
2558/// scan left-to-right for the first numeric `line` segment
2559/// and key off that rather than splitting blindly on colons.
2560fn parse_grep_line(binary: &str, raw: &str) -> Option<GrepHit> {
2561 let with_column = matches!(binary, "rg" | "ag") || binary.contains("rg");
2562 // Collect colon positions left-to-right; we'll walk pairs
2563 // looking for the first all-digits chunk between two
2564 // colons -- that's the line number, and everything before
2565 // is the path.
2566 let colon_idxs: Vec<usize> = raw
2567 .bytes()
2568 .enumerate()
2569 .filter_map(|(i, b)| (b == b':').then_some(i))
2570 .collect();
2571 for window in colon_idxs.windows(2) {
2572 let line_chunk = &raw[window[0] + 1..window[1]];
2573 if line_chunk.bytes().all(|b| b.is_ascii_digit()) && !line_chunk.is_empty() {
2574 let line: u32 = line_chunk.parse().ok()?;
2575 let path = &raw[..window[0]];
2576 if with_column {
2577 // Need a column next: look for another colon
2578 // after `window[1]` whose chunk between is all
2579 // digits.
2580 let after_line = window[1];
2581 let next_colon = colon_idxs.iter().find(|&&i| i > after_line)?;
2582 let col_chunk = &raw[after_line + 1..*next_colon];
2583 if col_chunk.bytes().all(|b| b.is_ascii_digit()) && !col_chunk.is_empty() {
2584 let col: u32 = col_chunk.parse().ok()?;
2585 let preview = raw[*next_colon + 1..].to_string();
2586 return Some(GrepHit {
2587 path: std::path::PathBuf::from(path),
2588 line: line.saturating_sub(1),
2589 col: col.saturating_sub(1),
2590 preview,
2591 });
2592 }
2593 continue;
2594 }
2595 // grep: path:line:text -- preview is everything
2596 // after the line's trailing colon.
2597 let preview = raw[window[1] + 1..].to_string();
2598 return Some(GrepHit {
2599 path: std::path::PathBuf::from(path),
2600 line: line.saturating_sub(1),
2601 col: 0,
2602 preview,
2603 });
2604 }
2605 }
2606 None
2607}
2608
2609/// `:picker outline`. Tree-sitter-driven symbol outline for
2610/// the active buffer. Reads `ctx.active_buffer.syntax_symbols`
2611/// (pre-collected by the host via
2612/// `Syntax::collect_symbol_locations`) and emits one row per
2613/// symbol, sorted by source position. Accept jumps the
2614/// cursor to the symbol via `JumpInBuffer`.
2615///
2616/// The LSP-flavored counterpart (`textDocument/documentSymbol`)
2617/// lives in `lattice-lsp::picker_sources` once the async-init
2618/// seat path lands; for now this source provides a
2619/// language-agnostic outline that works for every language
2620/// with a tree-sitter symbols query (`rust`, `python`,
2621/// `javascript` today; more as queries register).
2622pub struct OutlineSource {
2623 pub spec: PickerSourceSpec,
2624}
2625
2626impl OutlineSource {
2627 pub fn new() -> Self {
2628 Self {
2629 spec: PickerSourceSpec::no_args(
2630 "outline",
2631 "Tree-sitter symbol outline for the active buffer. `<CR>` jumps to the symbol.",
2632 ),
2633 }
2634 }
2635}
2636
2637impl Default for OutlineSource {
2638 fn default() -> Self {
2639 Self::new()
2640 }
2641}
2642
2643impl PickerSourceGenerator for OutlineSource {
2644 fn spec(&self) -> &PickerSourceSpec {
2645 &self.spec
2646 }
2647
2648 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
2649 if ctx.active_buffer.syntax_symbols.is_empty() {
2650 let lang = ctx.active_buffer.language.unwrap_or("plain");
2651 return Err(format!(
2652 "outline: no symbols (language `{lang}` has no tree-sitter query, or the parse tree is empty)"
2653 ));
2654 }
2655 let buffer_id = ctx.active_buffer.buffer_id;
2656 let pairs = ctx
2657 .active_buffer
2658 .syntax_symbols
2659 .iter()
2660 .map(|(name, line, col)| {
2661 // MP.4: the symbol name is the matchable `display`; the
2662 // line number moves to a `location` cell.
2663 let mut cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
2664 cand.annotations = vec![location_annotation(None, line + 1, None)];
2665 // PH.2: colour the symbol name with its line's syntax
2666 // spans, projected onto the name column.
2667 cand.display_spans = display_spans_for_symbol(
2668 &ctx.active_buffer.syntax_highlights,
2669 *line,
2670 *col,
2671 name.len(),
2672 );
2673 // Slice 7b.4: typed accept payload.
2674 cand.accept_action =
2675 Some(Box::new(lattice_completion::AcceptAction::JumpInBuffer {
2676 buffer_id: lattice_core::BufferId(buffer_id),
2677 line: *line,
2678 col: *col,
2679 }));
2680 (
2681 cand,
2682 RoutingPayload::JumpInBuffer {
2683 buffer_id,
2684 line: *line,
2685 col: *col,
2686 },
2687 )
2688 })
2689 .collect();
2690 Ok(PickerInitResult::Inline(pairs))
2691 }
2692
2693 fn accept(
2694 &self,
2695 _ctx: &PickerContext<'_>,
2696 routing: &RoutingPayload,
2697 ) -> SourceResult<PickerAcceptOutcome> {
2698 match routing {
2699 RoutingPayload::JumpInBuffer {
2700 buffer_id,
2701 line,
2702 col,
2703 } => Ok(PickerAcceptOutcome::JumpInBuffer {
2704 buffer_id: *buffer_id,
2705 line: *line,
2706 col: *col,
2707 }),
2708 other => Err(format!("outline: unexpected routing payload {other:?}")),
2709 }
2710 }
2711}
2712
2713/// Hard cap on the file-picker walker's emitted entry count.
2714/// At this scale the host's fuzzy matcher stays well inside the
2715/// per-keystroke frame budget; larger trees fall back to ripgrep-
2716/// style live filtering via `:grep` (P.10) or `:Filetree`'s
2717/// per-directory lazy walk.
2718pub const FILE_PICKER_MAX_ENTRIES: usize = 5000;
2719
2720/// Walk `root` recursively (BFS) and return the absolute paths
2721/// of every regular file (with its metadata), capped at
2722/// [`FILE_PICKER_MAX_ENTRIES`].
2723///
2724/// Uses the parallel, gitignore-aware `ignore` walker (the one
2725/// `lattice-multibuffer::search` uses): dotfiles are skipped (`hidden`),
2726/// .gitignore / .ignore / global + parent ignores are honoured, and the
2727/// build / VCS directories (`.git`, `target`, `node_modules`, `dist`,
2728/// `.cache`) are pruned even outside a git checkout. Symlinks aren't followed.
2729/// Each file's metadata is gathered during the walk (reusing the walk's own
2730/// stat) so a source's `init` need not run a second sequential stat pass —
2731/// that pass was the bulk of the first-open latency on a large tree.
2732///
2733/// Errors are silently absorbed (unreadable directories show up
2734/// as gaps in the listing); the picker UX prefers "some results"
2735/// over a hard failure when the workspace has a permission
2736/// pocket somewhere.
2737pub fn walk_files_for_picker(root: &std::path::Path) -> Vec<WalkedFile> {
2738 use ignore::{WalkBuilder, WalkState};
2739 use std::sync::Mutex;
2740 use std::sync::atomic::{AtomicUsize, Ordering};
2741
2742 // Non-git safety net: prune the heavy build / VCS directories even when the
2743 // tree is not a git checkout (where `ignore`'s .gitignore handling would not
2744 // catch them). In a real repo these are almost always gitignored too, so
2745 // this only matters in a bare directory.
2746 const PRUNE_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", ".cache"];
2747
2748 let out: std::sync::Arc<Mutex<Vec<WalkedFile>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
2749 let count = std::sync::Arc::new(AtomicUsize::new(0));
2750
2751 // `WalkBuilder`'s defaults already skip dotfiles (`hidden`) and honour
2752 // .gitignore / .ignore / global + parent ignores — the same walker
2753 // `lattice-multibuffer::search` uses. `build_parallel` fans the walk across
2754 // cores (the first-open win over the old single-threaded recursion), and
2755 // each file's `metadata()` is gathered HERE, reusing the walk's own stat,
2756 // rather than in a second sequential ≤5000-stat pass in the source's `init`.
2757 let mut builder = WalkBuilder::new(root);
2758 builder.filter_entry(|entry| {
2759 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
2760 let pruned = entry
2761 .file_name()
2762 .to_str()
2763 .map(|name| PRUNE_DIRS.contains(&name))
2764 .unwrap_or(false);
2765 !(is_dir && pruned)
2766 });
2767
2768 builder.build_parallel().run(|| {
2769 let out = std::sync::Arc::clone(&out);
2770 let count = std::sync::Arc::clone(&count);
2771 Box::new(move |result| {
2772 let Ok(entry) = result else {
2773 return WalkState::Continue;
2774 };
2775 let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
2776 if !is_file {
2777 return WalkState::Continue;
2778 }
2779 // A few threads may pass the cap before all observe the Quit; the
2780 // trailing `truncate` trims the overshoot.
2781 if count.fetch_add(1, Ordering::Relaxed) >= FILE_PICKER_MAX_ENTRIES {
2782 return WalkState::Quit;
2783 }
2784 let meta = entry.metadata().ok();
2785 if let Ok(mut guard) = out.lock() {
2786 guard.push((entry.into_path(), meta));
2787 }
2788 WalkState::Continue
2789 })
2790 });
2791
2792 let mut out = std::sync::Arc::try_unwrap(out)
2793 .ok()
2794 .and_then(|m| m.into_inner().ok())
2795 .unwrap_or_default();
2796 // Deterministic order: the parallel walk yields entries nondeterministically
2797 // and the fuzzy matcher reorders anyway, but a stable list keeps the
2798 // pre-filter view and the tests predictable.
2799 out.sort_by(|a, b| a.0.cmp(&b.0));
2800 out.truncate(FILE_PICKER_MAX_ENTRIES);
2801 out
2802}
2803
2804/// One walked file: its absolute path plus the metadata gathered during the
2805/// walk (`None` when the entry could not be stat'd). Returned by
2806/// [`walk_files_for_picker`] so a source's `init` builds candidates + their
2807/// marginalia without a second stat pass.
2808pub type WalkedFile = (std::path::PathBuf, Option<std::fs::Metadata>);
2809
2810/// A cache hit older than this is served immediately AND refreshed in the
2811/// background, so the next `:files` reflects on-disk changes without the open
2812/// re-walking. Short enough that a stale entry never lasts more than a beat;
2813/// long enough that rapid re-opens don't spawn a walk each time.
2814const FILE_WALK_REFRESH_TTL: std::time::Duration = std::time::Duration::from_secs(2);
2815
2816/// Process-global, in-memory session cache for the `:files` walk (Slice C:
2817/// background warm-up). Keyed by canonical root. **Never persisted** — the
2818/// picker must not show a stale tree across launches, so this lives and dies
2819/// with the process; the only staleness it can carry is bounded by
2820/// [`FILE_WALK_REFRESH_TTL`] within a session.
2821#[allow(clippy::type_complexity)]
2822fn file_walk_cache() -> &'static std::sync::Mutex<
2823 std::collections::HashMap<std::path::PathBuf, (Vec<WalkedFile>, std::time::Instant)>,
2824> {
2825 static CACHE: std::sync::OnceLock<
2826 std::sync::Mutex<
2827 std::collections::HashMap<std::path::PathBuf, (Vec<WalkedFile>, std::time::Instant)>,
2828 >,
2829 > = std::sync::OnceLock::new();
2830 CACHE.get_or_init(Default::default)
2831}
2832
2833/// Pre-walk `root` into the session cache so the first `:files` open is
2834/// instant. The host calls this from a background thread at boot (and on
2835/// project change); `FilesSource::init` also calls it to refresh a warm entry.
2836/// Runs the same [`walk_files_for_picker`] — off whatever thread the caller
2837/// spawns it on, never the UI thread.
2838pub fn warm_files_cache(root: &std::path::Path) {
2839 let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
2840 let walked = walk_files_for_picker(&canonical);
2841 if let Ok(mut cache) = file_walk_cache().lock() {
2842 cache.insert(canonical, (walked, std::time::Instant::now()));
2843 }
2844}
2845
2846/// The cached walk for `root` if it was warmed this session, cloned for the
2847/// caller. Returns `(entries, stale)` where `stale` marks an entry past
2848/// [`FILE_WALK_REFRESH_TTL`] — the caller serves it but should kick a refresh.
2849/// `None` on a cold cache.
2850fn cached_files(root: &std::path::Path) -> Option<(Vec<WalkedFile>, bool)> {
2851 let canonical = std::fs::canonicalize(root).ok()?;
2852 let cache = file_walk_cache().lock().ok()?;
2853 let (entries, walked_at) = cache.get(&canonical)?;
2854 Some((
2855 entries.clone(),
2856 walked_at.elapsed() >= FILE_WALK_REFRESH_TTL,
2857 ))
2858}
2859
2860/// Convenience: build the first-party source generators as
2861/// `Arc<dyn PickerSourceGenerator>` ready to register against
2862/// a `PickerRegistry`. Used by `App::new` (and a future host-
2863/// owned `Editor::boot`) to boot the registry. Sources that
2864/// need App-wide state captured at construction (e.g.
2865/// `CommandsSource` -> `CommandRegistry`, `GrepSource` ->
2866/// `ConfigRegistry`) take the relevant `Arc` here so the trait
2867/// surface stays state-handle-free.
2868pub fn first_party_generators(
2869 command_registry: CommandRegistryHandle,
2870 config: Arc<ConfigRegistry>,
2871 keybinding_reverse: Arc<dyn KeymapReverseLookup>,
2872 grep_highlighter: Option<Arc<dyn GrepPreviewHighlighter>>,
2873) -> Vec<Arc<dyn PickerSourceGenerator>> {
2874 vec![
2875 Arc::new(FilesSource::new()),
2876 Arc::new(FilePickSource::new()),
2877 Arc::new(DirPickSource::new()),
2878 Arc::new(YankRingSource::new()),
2879 Arc::new(RecentFilesSource::new()),
2880 Arc::new(BuffersSource::new()),
2881 Arc::new(LinesSource::new()),
2882 Arc::new(JumpsSource::new()),
2883 Arc::new(CommandsSource::new(command_registry, keybinding_reverse)),
2884 Arc::new(CommandHistorySource::new()),
2885 Arc::new(SearchHistorySource::new()),
2886 Arc::new(PaneBufferHistorySource::new()),
2887 Arc::new(RegistersSource::new()),
2888 Arc::new(MarksSource::new()),
2889 Arc::new(GrepSource::new(config, grep_highlighter)),
2890 Arc::new(OutlineSource::new()),
2891 ]
2892}
2893
2894#[cfg(test)]
2895mod tests {
2896 //! Unit tests for the pure private helpers (formatters,
2897 //! grep-line parser). The integration tests that need
2898 //! `app_with(...)` to build a real `PickerContext` snapshot
2899 //! stay in `lattice-ui-tui::picker_sources` -- they couple
2900 //! to the TUI's test-helper App constructor, not to the
2901 //! sources themselves. Slice 5.7.B.0 split the test layers
2902 //! so the renderer-neutral substrate's tests build without
2903 //! pulling ui-tui.
2904
2905 #![allow(clippy::unwrap_used, clippy::panic)]
2906
2907 use super::*;
2908
2909 /// `:picker files ~/notes` used to walk a directory literally named `~`.
2910 /// It found nothing and said "no files under ~/notes", which blames the
2911 /// directory for being empty rather than the path for never resolving.
2912 #[test]
2913 fn an_explicit_tilde_root_expands() {
2914 let home = lattice_core::home::expand_tilde("~");
2915 if !std::path::Path::new(&home).is_dir() {
2916 eprintln!("SKIP: no home directory to expand against");
2917 return;
2918 }
2919 let got =
2920 super::explicit_root_or(&["~/notes".to_string()], std::path::Path::new("/workspace"));
2921 assert_eq!(got, std::path::Path::new(&home).join("notes"));
2922 }
2923
2924 /// No argument means the workspace root — the whole point of `rooted`.
2925 #[test]
2926 fn no_argument_falls_back_to_the_workspace_root() {
2927 let ws = std::path::Path::new("/workspace");
2928 assert_eq!(super::explicit_root_or(&[], ws), ws);
2929 assert_eq!(super::explicit_root_or(&[String::new()], ws), ws);
2930 }
2931
2932 /// An absolute argument wins outright: the user saying "not that project,
2933 /// this one".
2934 #[test]
2935 fn an_absolute_root_is_taken_as_given() {
2936 assert_eq!(
2937 super::explicit_root_or(
2938 &["/elsewhere".to_string()],
2939 std::path::Path::new("/workspace")
2940 ),
2941 std::path::Path::new("/elsewhere")
2942 );
2943 }
2944
2945 /// Marginalia helpers: `format_size` matches the
2946 /// `ls -h` convention (bytes / K / M / G with one-decimal
2947 /// precision under 10 of each unit).
2948 #[test]
2949 fn format_size_humanizes_byte_counts() {
2950 assert_eq!(format_size(0), "0");
2951 assert_eq!(format_size(512), "512");
2952 assert_eq!(format_size(1024), "1.0K");
2953 assert_eq!(format_size(1024 * 9), "9.0K");
2954 assert_eq!(format_size(1024 * 10), "10K");
2955 assert_eq!(format_size(1024 * 70), "70K");
2956 assert_eq!(format_size(1024 * 1024), "1.0M");
2957 assert_eq!(format_size(1024 * 1024 * 12), "12M");
2958 assert_eq!(
2959 format_size(1024_u64.pow(3) * 4 + 1024_u64.pow(3) / 5),
2960 "4.2G"
2961 );
2962 }
2963
2964 /// `format_mtime_relative` produces stable English-y
2965 /// relative phrases. We don't test the boundary
2966 /// transitions exactly (they depend on wall-clock); we
2967 /// test category dispatch through synthesised deltas.
2968 #[test]
2969 fn format_mtime_relative_categorises_durations() {
2970 use std::time::{Duration, SystemTime};
2971
2972 let now = SystemTime::now();
2973 // 30 seconds ago -> "just now"
2974 let recent = now - Duration::from_secs(30);
2975 assert_eq!(format_mtime_relative(recent), "just now");
2976 // 3 minutes ago
2977 let mins = now - Duration::from_secs(3 * 60);
2978 assert_eq!(format_mtime_relative(mins), "3 minutes ago");
2979 // 1 minute ago (singular)
2980 let one_min = now - Duration::from_secs(70);
2981 assert_eq!(format_mtime_relative(one_min), "1 minute ago");
2982 // 28 hours ago (the user's example)
2983 let hours = now - Duration::from_secs(28 * 60 * 60);
2984 assert_eq!(format_mtime_relative(hours), "28 hours ago");
2985 // 5 days ago
2986 let days = now - Duration::from_secs(5 * 24 * 60 * 60);
2987 assert_eq!(format_mtime_relative(days), "5 days ago");
2988 }
2989
2990 /// MR.3: `perm_segments` yields one segment per bit class, each
2991 /// tagged with its theme slot, in `ls -l` shape. Bits map to the
2992 /// eza-convention slots; setuid/setgid/sticky fold into the exec
2993 /// positions as s/S/t/T against `perm.special`.
2994 #[cfg(unix)]
2995 #[test]
2996 fn perm_segments_map_bits_to_slots() {
2997 use std::os::unix::fs::PermissionsExt;
2998
2999 let tmp = std::env::temp_dir().join(format!(
3000 "lattice-perms-{}-{:?}",
3001 std::process::id(),
3002 std::thread::current().id()
3003 ));
3004 std::fs::write(&tmp, b"x").unwrap();
3005 // 0o755: rwx r-x r-x on a regular file.
3006 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).unwrap();
3007 let meta = std::fs::metadata(&tmp).unwrap();
3008 let segs = perm_segments(&meta);
3009 let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3010 assert_eq!(text, "-rwxr-xr-x", "ls -l shape");
3011 assert_eq!(segs.len(), 10);
3012 // Spot-check slot assignment for the user triad.
3013 assert_eq!(segs[0].slot.as_ref(), SLOT_PERM_TYPE); // '-'
3014 assert_eq!(segs[1].slot.as_ref(), SLOT_PERM_READ); // 'r'
3015 assert_eq!(segs[2].slot.as_ref(), SLOT_PERM_WRITE); // 'w'
3016 assert_eq!(segs[3].slot.as_ref(), SLOT_PERM_EXEC); // 'x'
3017 // Group write bit is absent → '-' on the `none` slot.
3018 assert_eq!(segs[5].text.as_ref(), "-");
3019 assert_eq!(segs[5].slot.as_ref(), SLOT_PERM_NONE);
3020
3021 // setuid + sticky: user-exec becomes 's', other-exec 't', both
3022 // on the special slot.
3023 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o4751)).unwrap();
3024 let meta = std::fs::metadata(&tmp).unwrap();
3025 let segs = perm_segments(&meta);
3026 let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3027 assert_eq!(text, "-rwsr-x--x", "setuid shows 's' in user-exec");
3028 assert_eq!(segs[3].text.as_ref(), "s");
3029 assert_eq!(segs[3].slot.as_ref(), SLOT_PERM_SPECIAL);
3030
3031 let _ = std::fs::remove_file(&tmp);
3032 }
3033
3034 /// MR.3: a stattable entry yields exactly the perm / size / mtime
3035 /// columns (in that order), each a `Styled` cell. `mtime` is present
3036 /// because temp files always carry a modified time.
3037 #[test]
3038 fn metadata_annotations_yields_perm_size_mtime() {
3039 use lattice_completion::Annotation;
3040 let tmp = std::env::temp_dir().join(format!(
3041 "lattice-meta-{}-{:?}",
3042 std::process::id(),
3043 std::thread::current().id()
3044 ));
3045 std::fs::write(&tmp, b"hello").unwrap();
3046 let meta = std::fs::metadata(&tmp).unwrap();
3047 let anns = metadata_annotations(&meta);
3048 let cats: Vec<&str> = anns.iter().map(|a| a.category()).collect();
3049 assert_eq!(cats, vec!["perm", "size", "mtime"]);
3050 // Every metadata annotation is a Styled cell.
3051 assert!(anns.iter().all(|a| matches!(a, Annotation::Styled { .. })));
3052 // The size cell carries the formatted size on the size slot.
3053 if let Annotation::Styled { segments, .. } = &anns[1] {
3054 assert_eq!(segments.len(), 1);
3055 assert_eq!(segments[0].text.as_ref(), "5");
3056 assert_eq!(segments[0].slot.as_ref(), SLOT_SIZE);
3057 } else {
3058 panic!("size annotation should be Styled");
3059 }
3060 let _ = std::fs::remove_file(&tmp);
3061 }
3062
3063 /// A directory renders with the `d` type char on `perm.type`.
3064 #[cfg(unix)]
3065 #[test]
3066 fn perm_segments_directory_type_char() {
3067 let dir = std::env::temp_dir().join(format!(
3068 "lattice-permdir-{}-{:?}",
3069 std::process::id(),
3070 std::thread::current().id()
3071 ));
3072 let _ = std::fs::create_dir(&dir);
3073 let meta = std::fs::metadata(&dir).unwrap();
3074 let segs = perm_segments(&meta);
3075 assert_eq!(segs[0].text.as_ref(), "d");
3076 assert_eq!(segs[0].slot.as_ref(), SLOT_PERM_TYPE);
3077 let _ = std::fs::remove_dir(&dir);
3078 }
3079
3080 /// MP.1: `location_segments` colors `path:line:col` — dim path, accent
3081 /// line, dim column — with `:` separators on the dim slots.
3082 #[test]
3083 fn location_segments_full_path_line_col() {
3084 let segs = location_segments(Some("src/main.rs"), 42, Some(7));
3085 let text: String = segs.iter().map(|s| s.text.as_ref()).collect();
3086 assert_eq!(text, "src/main.rs:42:7");
3087 assert_eq!(segs[0].slot.as_ref(), SLOT_LOC_PATH); // path
3088 assert_eq!(segs[1].slot.as_ref(), SLOT_LOC_PATH); // ":" sep
3089 assert_eq!(segs[2].slot.as_ref(), SLOT_LOC_LINE); // line
3090 assert_eq!(segs[3].slot.as_ref(), SLOT_LOC_COL); // ":" sep
3091 assert_eq!(segs[4].slot.as_ref(), SLOT_LOC_COL); // col
3092 }
3093
3094 /// MP.1: line-only location (no path, no col) for lines/outline pickers.
3095 #[test]
3096 fn location_segments_line_only() {
3097 let segs = location_segments(None, 12, None);
3098 assert_eq!(segs.len(), 1);
3099 assert_eq!(segs[0].text.as_ref(), "12");
3100 assert_eq!(segs[0].slot.as_ref(), SLOT_LOC_LINE);
3101 }
3102
3103 /// MP.1: status markers — active `•` then dirty `+`, each its own slot;
3104 /// empty when neither applies.
3105 #[test]
3106 fn status_segments_active_and_dirty() {
3107 assert!(status_segments(false, false).is_empty());
3108 let active = status_segments(false, true);
3109 assert_eq!(active.len(), 1);
3110 assert_eq!(active[0].slot.as_ref(), SLOT_STATUS_ACTIVE);
3111 let both = status_segments(true, true);
3112 assert_eq!(both.len(), 2);
3113 assert_eq!(both[0].slot.as_ref(), SLOT_STATUS_ACTIVE);
3114 assert_eq!(both[1].slot.as_ref(), SLOT_STATUS_DIRTY);
3115 }
3116
3117 /// MP.1: each latency class maps to its own slot.
3118 #[test]
3119 fn latency_segment_maps_class_to_slot() {
3120 assert_eq!(
3121 latency_segment(LatencyClass::Reflex).slot.as_ref(),
3122 SLOT_LATENCY_REFLEX
3123 );
3124 assert_eq!(
3125 latency_segment(LatencyClass::Display).slot.as_ref(),
3126 SLOT_LATENCY_DISPLAY
3127 );
3128 assert_eq!(
3129 latency_segment(LatencyClass::Background).slot.as_ref(),
3130 SLOT_LATENCY_BACKGROUND
3131 );
3132 }
3133
3134 /// MP.4: grep hits map to preview-as-display + a path:line:col
3135 /// `location` marginalia cell (1-based), routing to the file location.
3136 #[test]
3137 fn hits_to_pairs_emits_preview_and_location() {
3138 let pairs = hits_to_pairs(
3139 vec![GrepHit {
3140 path: std::path::PathBuf::from("src/main.rs"),
3141 line: 41,
3142 col: 6,
3143 preview: " let x = 1;".to_string(),
3144 }],
3145 None,
3146 );
3147 assert_eq!(pairs.len(), 1);
3148 let cand = &pairs[0].0;
3149 // Preview (trimmed) is the matchable display.
3150 assert_eq!(cand.display, "let x = 1;");
3151 let loc = cand
3152 .annotations
3153 .iter()
3154 .find(|a| a.category() == "location")
3155 .expect("location cell");
3156 assert_eq!(loc.display_text(), "src/main.rs:42:7");
3157 assert!(matches!(
3158 &pairs[0].1,
3159 RoutingPayload::LspLocation {
3160 line: 41,
3161 col: 6,
3162 ..
3163 }
3164 ));
3165 }
3166
3167 /// PH.3: when a highlighter is wired, grep previews carry its spans
3168 /// as `display_spans` (display-relative, since `display` is the
3169 /// trimmed preview); without one, previews stay plain.
3170 #[test]
3171 fn hits_to_pairs_attaches_highlighter_spans() {
3172 struct Stub;
3173 impl GrepPreviewHighlighter for Stub {
3174 fn highlight_line(
3175 &self,
3176 _path: &std::path::Path,
3177 line: &str,
3178 ) -> Vec<lattice_completion::DisplaySpan> {
3179 vec![lattice_completion::DisplaySpan {
3180 range: 0..line.len(),
3181 style: lattice_cells::style::Style::Keyword,
3182 }]
3183 }
3184 }
3185 let mk = || GrepHit {
3186 path: std::path::PathBuf::from("src/main.rs"),
3187 line: 0,
3188 col: 0,
3189 preview: " let x = 1;".to_string(),
3190 };
3191 // With a highlighter: spans attached, aligned to the trimmed display.
3192 let stub = Stub;
3193 let pairs = hits_to_pairs(vec![mk()], Some(&stub));
3194 let cand = &pairs[0].0;
3195 assert_eq!(cand.display, "let x = 1;");
3196 assert_eq!(cand.display_spans.len(), 1);
3197 assert_eq!(cand.display_spans[0].range, 0..cand.display.len());
3198 // Without one: plain preview.
3199 let plain = hits_to_pairs(vec![mk()], None);
3200 assert!(plain[0].0.display_spans.is_empty());
3201 }
3202
3203 /// Helper smoke: `format_args_hint` matches the
3204 /// emacs-style `<arg>` / `[<arg>]` convention.
3205 #[test]
3206 fn format_args_hint_renders_required_vs_optional() {
3207 use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec};
3208 let required = ArgSpec {
3209 name: "path".into(),
3210 kind: ArgKind::String,
3211 doc: "".into(),
3212 prompt: "".into(),
3213 default: ArgDefault::Required,
3214 completion: None,
3215 picker: None,
3216 };
3217 let optional = ArgSpec {
3218 default: ArgDefault::None,
3219 ..required.clone()
3220 };
3221 assert_eq!(format_args_hint(std::slice::from_ref(&required)), "<path>");
3222 assert_eq!(
3223 format_args_hint(std::slice::from_ref(&optional)),
3224 "[<path>]"
3225 );
3226 assert_eq!(format_args_hint(&[required, optional]), "<path> [<path>]");
3227 assert_eq!(format_args_hint(&[]), "");
3228 }
3229
3230 /// `parse_grep_line` decodes rg / ag format
3231 /// (`path:line:col:text`). Paths with colons (Windows
3232 /// drive letters, files with `:` in names) still parse
3233 /// because we key off the first numeric line segment, not
3234 /// blind colon-split.
3235 #[test]
3236 fn parse_grep_line_rg_format() {
3237 let hit = parse_grep_line("rg", "src/main.rs:42:7: let x = foo();").unwrap();
3238 assert_eq!(hit.path, std::path::PathBuf::from("src/main.rs"));
3239 assert_eq!(hit.line, 41);
3240 assert_eq!(hit.col, 6);
3241 assert_eq!(hit.preview, " let x = foo();");
3242 }
3243
3244 /// `parse_grep_line` decodes plain `grep -rn` format
3245 /// (`path:line:text`, no column).
3246 #[test]
3247 fn parse_grep_line_grep_format() {
3248 let hit = parse_grep_line("grep", "src/main.rs:42: let x = foo();").unwrap();
3249 assert_eq!(hit.path, std::path::PathBuf::from("src/main.rs"));
3250 assert_eq!(hit.line, 41);
3251 assert_eq!(hit.col, 0);
3252 assert_eq!(hit.preview, " let x = foo();");
3253 }
3254
3255 /// `walk_files_for_picker` walks a temp tree, honouring
3256 /// the dotfile + ignore-dir filters. Co-located with the
3257 /// walker so the sibling test in ui-tui's app/picker.rs
3258 /// (which referenced `super::walk_files_for_picker`) can
3259 /// retire post-move.
3260 #[test]
3261 fn walk_files_for_picker_honours_dotfile_and_ignore_filters() {
3262 let tmp = std::env::temp_dir().join(format!("lattice-walk-{}", std::process::id()));
3263 let _ = std::fs::remove_dir_all(&tmp);
3264 std::fs::create_dir_all(&tmp).unwrap();
3265 std::fs::write(tmp.join("a.rs"), "").unwrap();
3266 std::fs::write(tmp.join("b.rs"), "").unwrap();
3267 std::fs::create_dir(tmp.join("sub")).unwrap();
3268 std::fs::write(tmp.join("sub").join("c.rs"), "").unwrap();
3269 // Ignored: dotfile and ignore-dir.
3270 std::fs::write(tmp.join(".secret"), "").unwrap();
3271 std::fs::create_dir(tmp.join("target")).unwrap();
3272 std::fs::write(tmp.join("target").join("d.rs"), "").unwrap();
3273 let entries = walk_files_for_picker(&tmp);
3274 let names: Vec<String> = entries
3275 .iter()
3276 .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3277 .collect();
3278 assert!(names.iter().any(|n| n == "a.rs"));
3279 assert!(names.iter().any(|n| n == "b.rs"));
3280 assert!(names.iter().any(|n| n == "c.rs"));
3281 assert!(!names.iter().any(|n| n == ".secret"));
3282 assert!(!names.iter().any(|n| n == "d.rs"));
3283 let _ = std::fs::remove_dir_all(&tmp);
3284 }
3285
3286 /// The parallel walk honours a repo `.gitignore` (the old hand-rolled walk
3287 /// only knew a hardcoded dir list), and the ≤`FILE_PICKER_MAX_ENTRIES` cap
3288 /// holds even though threads race past it before all observe the quit.
3289 #[test]
3290 fn walk_files_for_picker_respects_gitignore_and_caps() {
3291 let tmp = std::env::temp_dir().join(format!(
3292 "lattice-walk-gi-{}-{}",
3293 std::process::id(),
3294 std::time::SystemTime::now()
3295 .duration_since(std::time::UNIX_EPOCH)
3296 .map(|d| d.as_nanos())
3297 .unwrap_or(0)
3298 ));
3299 let _ = std::fs::remove_dir_all(&tmp);
3300 std::fs::create_dir_all(&tmp).unwrap();
3301 // A real git repo so `ignore` activates .gitignore handling.
3302 std::fs::create_dir_all(tmp.join(".git")).unwrap();
3303 std::fs::write(tmp.join(".gitignore"), "ignored.rs\nbuildout/\n").unwrap();
3304 std::fs::write(tmp.join("kept.rs"), "").unwrap();
3305 std::fs::write(tmp.join("ignored.rs"), "").unwrap();
3306 std::fs::create_dir(tmp.join("buildout")).unwrap();
3307 std::fs::write(tmp.join("buildout").join("gen.rs"), "").unwrap();
3308
3309 let names: Vec<String> = walk_files_for_picker(&tmp)
3310 .iter()
3311 .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3312 .collect();
3313 assert!(names.iter().any(|n| n == "kept.rs"), "tracked file kept");
3314 assert!(
3315 !names.iter().any(|n| n == "ignored.rs"),
3316 "gitignored file excluded (the hand-rolled walk could not do this)"
3317 );
3318 assert!(
3319 !names.iter().any(|n| n == "gen.rs"),
3320 "file under a gitignored dir excluded"
3321 );
3322
3323 // Cap: many files, walked in parallel, must not exceed the ceiling.
3324 let big = tmp.join("many");
3325 std::fs::create_dir(&big).unwrap();
3326 for i in 0..(FILE_PICKER_MAX_ENTRIES + 200) {
3327 std::fs::write(big.join(format!("f{i}.rs")), "").unwrap();
3328 }
3329 assert!(
3330 walk_files_for_picker(&tmp).len() <= FILE_PICKER_MAX_ENTRIES,
3331 "the parallel walk must honour the entry cap"
3332 );
3333 let _ = std::fs::remove_dir_all(&tmp);
3334 }
3335
3336 /// Slice C: warming the session cache (what the host does on a background
3337 /// thread at boot) makes a subsequent read a hit — the first `:files` open
3338 /// is then served instantly instead of re-walking. A never-warmed root is a
3339 /// cold miss.
3340 #[test]
3341 fn warm_files_cache_serves_a_subsequent_read() {
3342 let tmp = std::env::temp_dir().join(format!(
3343 "lattice-walk-cache-{}-{}",
3344 std::process::id(),
3345 std::time::SystemTime::now()
3346 .duration_since(std::time::UNIX_EPOCH)
3347 .map(|d| d.as_nanos())
3348 .unwrap_or(0)
3349 ));
3350 let _ = std::fs::remove_dir_all(&tmp);
3351 std::fs::create_dir_all(&tmp).unwrap();
3352 std::fs::write(tmp.join("one.rs"), "").unwrap();
3353 std::fs::write(tmp.join("two.rs"), "").unwrap();
3354
3355 // Cold: nothing cached for this fresh dir.
3356 assert!(cached_files(&tmp).is_none(), "cold cache is a miss");
3357
3358 // Warm it, then the read is a hit — served from the session cache and,
3359 // being freshly walked, not stale.
3360 warm_files_cache(&tmp);
3361 let (entries, stale) = cached_files(&tmp).expect("warmed cache is a hit");
3362 assert!(!stale, "a just-warmed entry is fresh");
3363 let names: Vec<String> = entries
3364 .iter()
3365 .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
3366 .collect();
3367 assert!(names.iter().any(|n| n == "one.rs"));
3368 assert!(names.iter().any(|n| n == "two.rs"));
3369 let _ = std::fs::remove_dir_all(&tmp);
3370 }
3371}
3372
3373/// PC.9 — `dir-pick`'s pure halves: what it lists, and where it starts.
3374///
3375/// The hooks that need a real [`PickerContext`] (`descend` through a
3376/// keystroke, `init` through a seated picker) are exercised in
3377/// `lattice-ui-tui::picker_sources`, which is where this module's own doc
3378/// comment says context-needing tests live.
3379#[cfg(test)]
3380mod dir_pick_tests {
3381 #![allow(clippy::unwrap_used, clippy::panic)]
3382
3383 use super::*;
3384
3385 /// A tree with two subdirectories and a file, so "directories only" is
3386 /// falsifiable rather than vacuous.
3387 fn tree() -> tempfile::TempDir {
3388 let dir = tempfile::TempDir::new().unwrap();
3389 std::fs::create_dir_all(dir.path().join("alpha")).unwrap();
3390 std::fs::create_dir_all(dir.path().join("beta")).unwrap();
3391 std::fs::write(dir.path().join("gamma.txt"), "not a directory\n").unwrap();
3392 dir
3393 }
3394
3395 fn texts(rows: &[(RawCandidate, RoutingPayload)]) -> Vec<String> {
3396 rows.iter().map(|(c, _)| c.text.clone()).collect()
3397 }
3398
3399 /// The empty query lists the start directory — and the rows carry their
3400 /// full path, not bare names. That is what makes the first `<C-l>` behave
3401 /// like every later one.
3402 ///
3403 /// PP.1 put `../` in front of them. It is first because going up is the
3404 /// one destination that is never in the listing, so a row for it that
3405 /// sorted among the children would be lost in a long one.
3406 #[test]
3407 fn an_empty_query_lists_the_start_directory_with_full_paths() {
3408 let dir = tree();
3409 let start = dir.path().to_string_lossy().to_string();
3410 let parent = std::path::Path::new(&start)
3411 .parent()
3412 .unwrap()
3413 .to_string_lossy()
3414 .to_string();
3415 let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3416
3417 assert_eq!(
3418 texts(&rows),
3419 vec![
3420 format!("{parent}/"),
3421 format!("{start}/alpha/"),
3422 format!("{start}/beta/")
3423 ],
3424 "`../` first, then both subdirectories, each spelled from the start \
3425 directory"
3426 );
3427 assert_eq!(
3428 rows[0].0.display, "../",
3429 "and it READS as `../` — the path it resolves to is already in the \
3430 prompt, so what the row adds is the verb"
3431 );
3432 }
3433
3434 /// PP.1: `../` is an ORDINARY row. `<C-l>` descends into it because its
3435 /// text ends in `/`, and `<CR>` supplies the parent because that is what
3436 /// every other row does with its own path. Pinned, because a synthetic
3437 /// go-up row with its own accept semantics would be a second answer to a
3438 /// question `descend` already answers.
3439 #[test]
3440 fn the_parent_row_descends_and_supplies_like_any_other() {
3441 let dir = tree();
3442 let start = dir.path().to_string_lossy().to_string();
3443 let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3444 let (cand, routing) = &rows[0];
3445
3446 assert!(
3447 cand.text.ends_with('/'),
3448 "`descend` takes any row whose text ends in `/`: {}",
3449 cand.text
3450 );
3451 let RoutingPayload::SuppliedValue { value } = routing else {
3452 panic!("`../` supplies a value like every other row: {routing:?}");
3453 };
3454 assert_eq!(
3455 std::path::Path::new(value),
3456 std::path::Path::new(&start).parent().unwrap(),
3457 "and the value is the parent directory itself"
3458 );
3459 }
3460
3461 /// **`../` and `<C-w>` must land in the same place.** They share
3462 /// `parent_of` for exactly this reason: two ways to go up that arrive
3463 /// somewhere different is an inconsistency nobody reports and everybody
3464 /// trips on.
3465 #[test]
3466 fn the_parent_row_and_the_ascend_key_agree() {
3467 let source = DirPickSource::new();
3468 for query in ["/tmp/", "~/src/", "~/"] {
3469 let row = DirPickSource::parent_row(query).map(|(c, _)| c.text);
3470 assert_eq!(
3471 row,
3472 source.ascend(query),
3473 "`../` and `<C-w>` disagree about the parent of {query}"
3474 );
3475 }
3476 }
3477
3478 /// ascend at `~/` used to CLEAR the query, which re-listed `~/` — a key
3479 /// that visibly did nothing. Home's parent is spelled absolutely because
3480 /// the tilde form cannot name it, which is the one case where the query's
3481 /// own text is not enough.
3482 #[test]
3483 fn home_has_a_parent_spelled_absolutely() {
3484 let home = lattice_core::home::expand_tilde("~");
3485 if !std::path::Path::new(&home).is_dir() {
3486 eprintln!("SKIP: no home directory to expand against");
3487 return;
3488 }
3489 let up = DirPickSource::parent_of("~/").expect("home has a parent");
3490 // `is_absolute`, not `starts_with('/')`: on Windows it is `C:\Users/`.
3491 assert!(
3492 std::path::Path::new(&up).is_absolute() && up.ends_with('/'),
3493 "absolute, and a listing prefix: {up}"
3494 );
3495 assert_eq!(
3496 std::path::Path::new(up.trim_end_matches('/')),
3497 std::path::Path::new(&home).parent().unwrap()
3498 );
3499 }
3500
3501 /// The root is where going up stops. A `../` row there would offer a
3502 /// destination that does not exist.
3503 #[test]
3504 fn the_root_offers_no_way_up() {
3505 assert_eq!(DirPickSource::parent_of("/"), None);
3506 assert!(
3507 !texts(&DirPickSource::rows("/")).iter().any(|t| t == "/"),
3508 "no row pointing `/` at itself"
3509 );
3510 }
3511
3512 /// PP.1: the picker opens ON the start directory, so the prompt says
3513 /// where you are from the first frame.
3514 ///
3515 /// The trailing `/` is the assertion that matters: without it the seeded
3516 /// query is a FILTER (`path_entries("/tmp")` lists `/`'s children whose
3517 /// names start with `tmp`) rather than a listing, which is exactly what
3518 /// `:picker dir-pick /tmp` used to do.
3519 #[test]
3520 fn the_query_opens_on_the_start_directory() {
3521 let source = DirPickSource::new();
3522 assert_eq!(
3523 source.initial_query(&["/tmp".to_string()]),
3524 Some("/tmp/".to_string()),
3525 "an argument without a trailing slash is normalised into a listing"
3526 );
3527 assert_eq!(
3528 source.initial_query(&["/tmp/".to_string()]),
3529 Some("/tmp/".to_string()),
3530 "and one with it is left alone"
3531 );
3532 assert_eq!(
3533 source.initial_query(&[]),
3534 Some("~/".to_string()),
3535 "no argument opens on home, which is where this source starts"
3536 );
3537 }
3538
3539 /// A basename filter is not a listing, and `../` must not survive it: it
3540 /// would be the one row in a filtered set that is not a match.
3541 #[test]
3542 fn a_filtered_listing_offers_no_parent_row() {
3543 let dir = tree();
3544 let start = dir.path().to_string_lossy().to_string();
3545 let rows = DirPickSource::rows(&format!("{start}/al"));
3546
3547 assert_eq!(texts(&rows), vec![format!("{start}/alpha/")]);
3548 }
3549
3550 /// Files are not directories. A source that listed them would hand back a
3551 /// path its caller cannot use as one.
3552 #[test]
3553 fn a_regular_file_is_never_a_row() {
3554 let dir = tree();
3555 let start = dir.path().to_string_lossy().to_string();
3556 let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3557
3558 assert!(
3559 !texts(&rows).iter().any(|t| t.contains("gamma")),
3560 "`gamma.txt` exists in the tree and must not be offered: {:?}",
3561 texts(&rows)
3562 );
3563 }
3564
3565 /// The basename after the last `/` filters, which is what makes typing
3566 /// narrow rather than restart.
3567 #[test]
3568 fn a_partial_basename_filters_the_listing() {
3569 let dir = tree();
3570 let start = dir.path().to_string_lossy().to_string();
3571 let rows = DirPickSource::rows(&format!("{start}/al"));
3572
3573 assert_eq!(texts(&rows), vec![format!("{start}/alpha/")]);
3574 }
3575
3576 /// **An unreadable query is an empty list, not an error.** Half a typed
3577 /// path names nothing yet, and that is the state the user is in for most
3578 /// of the keystrokes — erroring on it would mean the picker spends its
3579 /// life reporting failure.
3580 #[test]
3581 fn a_query_naming_nothing_yields_an_empty_list() {
3582 let dir = tree();
3583 let start = dir.path().to_string_lossy().to_string();
3584
3585 assert!(DirPickSource::rows(&format!("{start}/no-such-dir/")).is_empty());
3586 assert!(DirPickSource::rows("/definitely/not/a/real/path/").is_empty());
3587 }
3588
3589 /// The row's TEXT keeps the query's spelling (what the user reads and
3590 /// descends from); the VALUE it supplies is the expanded absolute path
3591 /// (what a consumer resolves). A consumer should not have to know about
3592 /// `~`.
3593 #[test]
3594 fn the_supplied_value_is_the_expanded_path_even_when_the_text_is_not() {
3595 let home = lattice_core::home::expand_tilde("~");
3596 if !std::path::Path::new(&home).is_dir() {
3597 eprintln!("SKIP: no home directory to expand against");
3598 return;
3599 }
3600 let rows = DirPickSource::rows("~/");
3601 // Past `../`: that row is the deliberate exception to the spelling
3602 // rule, because the tilde form cannot name home's parent (PP.1).
3603 let Some((cand, routing)) = rows.iter().find(|(c, _)| c.display != "../") else {
3604 eprintln!("SKIP: the home directory has no subdirectories");
3605 return;
3606 };
3607 let RoutingPayload::SuppliedValue { value } = routing else {
3608 panic!("dir-pick supplies values: {routing:?}");
3609 };
3610
3611 assert!(
3612 cand.text.starts_with("~/"),
3613 "the row keeps the spelling the user typed: {}",
3614 cand.text
3615 );
3616 assert!(
3617 value.starts_with(&home) && !value.starts_with('~'),
3618 "the value is expanded: {value}"
3619 );
3620 }
3621
3622 /// Every directory row ends in `/`, which is both what `descend` keys off
3623 /// and the prefix that lists a directory's CONTENTS rather than its
3624 /// siblings.
3625 #[test]
3626 fn every_row_ends_in_a_slash_so_descending_lists_its_children() {
3627 let dir = tree();
3628 let start = dir.path().to_string_lossy().to_string();
3629 let rows = DirPickSource::rows(&DirPickSource::prefix_for(&start, ""));
3630
3631 assert!(
3632 !rows.is_empty(),
3633 "precondition: the tree has subdirectories"
3634 );
3635 for (cand, _) in &rows {
3636 assert!(
3637 cand.text.ends_with('/'),
3638 "row without a slash: {}",
3639 cand.text
3640 );
3641 // The round trip descend relies on: this row's own text, used as
3642 // the next query, lists what is inside it.
3643 //
3644 // `../` is excluded from the *inner* listing, not from the slash
3645 // rule above: it is the one row that points OUT, so the listing it
3646 // produces contains its own `../` pointing further out, which is
3647 // not under the query by construction. Excluding the whole row
3648 // instead would stop checking that `<C-l>` on `../` works at all.
3649 assert!(
3650 DirPickSource::rows(&cand.text)
3651 .iter()
3652 .filter(|(c, _)| c.display != "../")
3653 .all(|(c, _)| c.text.starts_with(&cand.text)),
3654 "descending into {} must list its children",
3655 cand.text
3656 );
3657 }
3658 }
3659
3660 /// A start already ending in `/` must not become `//`.
3661 #[test]
3662 fn the_start_prefix_carries_exactly_one_slash() {
3663 assert_eq!(DirPickSource::prefix_for("/tmp", ""), "/tmp/");
3664 assert_eq!(DirPickSource::prefix_for("/tmp/", ""), "/tmp/");
3665 assert_eq!(DirPickSource::prefix_for("~", ""), "~/");
3666 }
3667
3668 /// A non-empty query IS the prefix — the start is only ever a seed.
3669 #[test]
3670 fn a_non_empty_query_replaces_the_start_entirely() {
3671 assert_eq!(DirPickSource::prefix_for("/tmp", "/etc/x"), "/etc/x");
3672 }
3673
3674 /// Home, not the workspace root — see the type's own doc for why the
3675 /// asymmetry with `file-pick` is deliberate. Pinned because "make it
3676 /// consistent with `file-pick`" is exactly the tidy-looking change that
3677 /// would break the motivating case.
3678 #[test]
3679 fn browsing_starts_at_home_unless_told_otherwise() {
3680 assert_eq!(DirPickSource::start_dir(&[]), "~");
3681 assert_eq!(DirPickSource::start_dir(&[String::new()]), "~");
3682 assert_eq!(DirPickSource::start_dir(&["/srv".to_string()]), "/srv");
3683 }
3684
3685 /// The source declares `live`, and it must: the query is a PATH, so the
3686 /// picker's fuzzy refilter would rank `~/src/dh` against bare child names
3687 /// instead of listing what is under `~/src/`. The two declarations are
3688 /// paired by the trait's contract.
3689 #[test]
3690 fn the_source_is_live_because_its_query_is_a_path() {
3691 assert!(DirPickSource::new().spec().live);
3692 }
3693}