Skip to main content

strop_engine/editor/picker/
mod.rs

1//! Picker glue: workers post onto the editor event loop, every stream
2//! owned by an exact ticket (R9): registration precedes launch, every
3//! request settles exactly once, and stale streams die at the handler
4//! instead of against the model (0020 §2).
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::mpsc::{channel, Receiver};
9
10use strop_core::worker::{CancelHandle, CancelReason, Load, Ticket, WorkerId};
11use strop_picker::{spawn_files, GrepWorker, Item, Kind, Payload, Picker, PickerMsg};
12
13use super::{Editor, Key};
14
15mod accept;
16mod drain;
17mod preview;
18mod query;
19#[cfg(test)]
20mod query_tests;
21pub(crate) mod ranking;
22mod replace;
23pub use replace::checked_hit_range;
24pub use replace::ReplacementHit;
25pub(crate) mod search;
26pub use search::SearchScope;
27#[cfg(test)]
28mod search_tests;
29#[cfg(test)]
30mod tests;
31
32/// One picker instance's identity, allocated from the editor's worker
33/// id pool when the picker opens. Every streaming request and preview
34/// read binds to it — closing the picker invalidates them all at once.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[serde(transparent)]
37pub struct PickerId(pub WorkerId);
38
39/// What one streaming picker request owns: which instance, against
40/// which working directory.
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct PickerKey {
43    pub picker: PickerId,
44    #[serde(with = "strop_core::path_serde")]
45    pub cwd: PathBuf,
46}
47
48/// A worker message stamped with the request that produced it. Both
49/// the TUI's forwarded events and the headless drain deliver these;
50/// only the owning ticket may touch the model.
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PickerEvent {
53    pub ticket: Ticket<PickerKey>,
54    pub msg: PickerMsg,
55}
56
57/// One supervised preview read: the picker instance it serves and the
58/// native path being read.
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct PreviewKey {
61    pub picker: PickerId,
62    #[serde(with = "strop_core::path_serde")]
63    pub path: PathBuf,
64}
65
66/// The terminal result of a preview request.
67pub type PreviewResult = strop_core::worker::Completion<PreviewKey, preview::PreparedPreview>;
68
69/// The live worker behind a streaming request.
70pub(crate) enum PickerWorker {
71    Files(CancelHandle),
72    Grep(GrepWorker),
73}
74
75impl PickerWorker {
76    pub(crate) fn cancel(self, reason: CancelReason) {
77        match self {
78            PickerWorker::Files(handle) => handle.cancel(reason),
79            PickerWorker::Grep(worker) => worker.cancel(reason),
80        }
81    }
82}
83
84pub struct PickerGlue {
85    pub picker: Picker,
86    pub id: PickerId,
87    /// The request owning the stream: set at launch, cleared by its
88    /// terminal Finished event or by cancellation.
89    pub(crate) active: Option<Ticket<PickerKey>>,
90    /// Headless only: the active request's raw stream (the TUI gets a
91    /// ticket-stamping bridge at launch instead).
92    pub(crate) rx: Option<(Ticket<PickerKey>, Receiver<PickerMsg>)>,
93    pub(crate) worker: Option<PickerWorker>,
94    pub(crate) lsp_context: Option<strop_lsp::ReplyContext>,
95    pub(crate) rank_worker: Option<strop_picker::RankingWorker<ranking::Key>>,
96    pub rank_pending: Option<Ticket<ranking::Key>>,
97    /// Coalesce catalog/query changes while one rank snapshot is in flight.
98    pub(crate) rank_dirty: bool,
99    pub(crate) ranked_query: Option<String>,
100    pub(crate) rank_alive: bool,
101    pub(crate) accept_when_ranked: bool,
102    /// The manual query-suggestion list (0051 R02): ctrl-space opens
103    /// it; it owns accept/cancel keys until dismissed.
104    pub suggestions: Option<SuggestionList>,
105    pub query_highlights: Vec<strop_picker::query::HighlightSpan>,
106    pub query_summary: String,
107    pub(crate) query: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
108    file_scope: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
109    pub(crate) indent_target: Option<strop_core::id::DocumentId>,
110    pub(crate) search: Option<search::SearchContext>,
111    preview_witness: Option<preview::WitnessCheck>,
112}
113
114/// The visible suggestion list: static candidates from the query's own
115/// parse position — never an LSP or a filesystem scan.
116pub struct SuggestionList {
117    pub items: Vec<strop_picker::query::suggest::Suggestion>,
118    pub selected: usize,
119}
120
121impl PickerGlue {
122    /// A picker with no request yet: `Editor::set_picker` allocates the
123    /// instance identity before the glue is installed; Files/grep
124    /// requests are launched afterwards by `Editor::open_picker` and
125    /// `picker_input_changed`. (LSP location lists never launch one.)
126    pub fn diagnostics(picker: Picker) -> Self {
127        Self {
128            picker,
129            id: PickerId(WorkerId::new(0)), // replaced on install
130            active: None,
131            rx: None,
132            worker: None,
133            lsp_context: None,
134            rank_worker: None,
135            rank_pending: None,
136            rank_dirty: false,
137            ranked_query: None,
138            rank_alive: false,
139            accept_when_ranked: false,
140            suggestions: None,
141            query_highlights: Vec::new(),
142            query_summary: String::new(),
143            file_scope: None,
144            query: None,
145            indent_target: None,
146            search: None,
147            preview_witness: None,
148        }
149    }
150
151    /// Revoke the active request without touching the model: cancel
152    /// the worker (a queued terminal event is rejected later — it
153    /// cannot regain authority) and drop the raw stream.
154    fn revoke(&mut self, reason: CancelReason) {
155        self.rx = None;
156        if self.active.take().is_some() {
157            if let Some(worker) = self.worker.take() {
158                worker.cancel(reason);
159            }
160        }
161    }
162}
163
164impl Editor {
165    /// Install a picker: tears down any previous instance (revoking
166    /// its streams and previews) and allocates a fresh identity from
167    /// the worker id pool.
168    pub(crate) fn set_picker(&mut self, mut glue: PickerGlue) {
169        self.cancel_pending();
170        self.close_picker();
171        let id = match self.worker_ids.allocate() {
172            Ok(id) => id,
173            Err(error) => {
174                self.message = error.message;
175                return;
176            }
177        };
178        glue.preview_witness = None;
179        glue.id = PickerId(id);
180        if glue.picker.kind == Kind::Search && glue.search.is_none() {
181            match self.new_search_context(SearchScope {
182                root: strop_workspace::ResourceLocation::local(self.cwd.clone()),
183            }) {
184                Ok(context) => glue.search = Some(context),
185                Err(error) => {
186                    self.message = error;
187                    return;
188                }
189            }
190        }
191        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
192            serde_json::json!({
193                "service":"picker","id":id.get(),
194                "kind":glue.picker.kind.title().trim(),"streaming":glue.picker.streaming,
195            })
196        });
197        self.picker = Some(glue);
198        if self
199            .picker
200            .as_ref()
201            .is_some_and(|glue| glue.picker.kind != Kind::RemoteAddress)
202        {
203            self.start_picker_ranking();
204        }
205    }
206
207    pub fn open_picker(&mut self, kind: Kind) {
208        if kind == Kind::Search {
209            self.open_search(false);
210            return;
211        }
212        if kind == Kind::RemoteHosts {
213            self.open_remote_picker();
214            return;
215        }
216        if kind == Kind::Jumps {
217            self.open_jumps_picker();
218            return;
219        }
220        if kind == Kind::RemoteAddress {
221            self.open_remote_address();
222            return;
223        }
224        if kind == Kind::TabSize {
225            self.open_tab_size_picker();
226            return;
227        }
228        let items = match kind {
229            Kind::Buffers => self
230                .mru
231                .iter()
232                .map(|&i| {
233                    let name = match self.doc(i).buf.path.as_ref() {
234                        Some(path) => path.to_string_lossy().into_owned(),
235                        None => "[scratch]".into(),
236                    };
237                    Item {
238                        badge: None,
239                        text: name,
240                        payload: Payload::Buffer(i),
241                    }
242                })
243                .collect(),
244            // Grep/Replace stream only once input registers a request;
245            // Files launches its walk right after install.
246            Kind::Files
247            | Kind::Search
248            | Kind::RemoteHosts
249            | Kind::RemoteAddress
250            | Kind::CodeActions
251            | Kind::Containers => vec![],
252            Kind::Jumps => unreachable!("the jumplist builds its own items"),
253            Kind::SearchOptions => unreachable!("search options build their own items"),
254            Kind::TabSize => unreachable!("the tab-size selector builds its own items"),
255            Kind::Symbols => vec![],
256            Kind::Diagnostics | Kind::Locations => {
257                unreachable!("location lists use PickerGlue::diagnostics")
258            }
259        };
260        self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
261        if kind == Kind::Files {
262            self.picker_input_changed();
263        }
264    }
265
266    /// `:search-options` (0051 R03): the hidden/ignore controls with
267    /// their live values; Enter toggles and the row updates in place.
268    pub(crate) fn open_search_options(&mut self) {
269        let item = |setting: strop_picker::SearchSetting, on: bool| strop_picker::Item {
270            badge: None,
271            text: format!(
272                "{}: {}",
273                match setting {
274                    strop_picker::SearchSetting::Hidden => "hidden (dotfiles)",
275                    strop_picker::SearchSetting::RespectIgnore => "ignored entries",
276                },
277                match (setting, on) {
278                    (strop_picker::SearchSetting::Hidden, true)
279                    | (strop_picker::SearchSetting::RespectIgnore, false) => "include",
280                    _ => "exclude",
281                },
282            ),
283            payload: strop_picker::Payload::SearchOption(setting),
284        };
285        let items = vec![
286            item(
287                strop_picker::SearchSetting::Hidden,
288                self.config.search_show_hidden,
289            ),
290            item(
291                strop_picker::SearchSetting::RespectIgnore,
292                self.config.search_respect_ignore,
293            ),
294        ];
295        self.set_picker(PickerGlue::diagnostics(Picker::new(
296            Kind::SearchOptions,
297            items,
298            false,
299        )));
300    }
301
302    /// The jumplist as a menu (0047 §2): past newest-first, the current
303    /// position marked, then the future; dead documents are filtered.
304    pub(crate) fn open_jumps_picker(&mut self) {
305        let mut items = Vec::new();
306        for entry in self.jumplist_past.iter().rev() {
307            items.extend(jump_row(self, entry, "  "));
308        }
309        items.extend(jump_row(self, &self.jump_record(), "> "));
310        for entry in self.jumplist_future.iter().rev() {
311            items.extend(jump_row(self, entry, "  "));
312        }
313        self.set_picker(PickerGlue::diagnostics(Picker::new(
314            Kind::Jumps,
315            items,
316            false,
317        )));
318    }
319
320    /// Hand a launched request's stream to the app event loop (TUI) or
321    /// keep it for the headless drain.
322    fn attach_picker_stream(&mut self, ticket: Ticket<PickerKey>, rx: Receiver<PickerMsg>) {
323        let Some(app_tx) = self.app_tx.clone() else {
324            if let Some(glue) = self.picker.as_mut() {
325                glue.rx = Some((ticket, rx));
326            }
327            return;
328        };
329        if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), app_tx) {
330            // the bridge thread could not start: settle the request now
331            self.handle_picker_event(PickerEvent {
332                ticket,
333                msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
334                    strop_core::worker::FailureKind::ThreadStart,
335                    format!("picker bridge: {error}"),
336                )),
337            });
338        }
339    }
340
341    /// Connect-time: hand any already-registered headless stream to
342    /// the app channel (normally requests attach at launch).
343    pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
344        if let Some(glue) = &mut self.picker {
345            if let Some((ticket, rx)) = glue.rx.take() {
346                let _ = drain::forward_picker_stream(rx, ticket, tx.clone());
347            }
348        }
349    }
350
351    /// Close the picker: revoke its active request, stop its worker,
352    /// and cancel/forget the previews it owns (failed reads become
353    /// retryable on reopen; retained successful bytes must be revalidated).
354    pub fn close_picker(&mut self) {
355        let Some(mut glue) = self.picker.take() else {
356            return;
357        };
358        glue.revoke(CancelReason::OwnerClosed);
359        self.revoke_remote_chooser(glue.id);
360        self.revoke_picker_previews(glue.id);
361        if glue.rank_alive {
362            self.picker_ranking.retiring.insert(glue.id);
363        }
364        if glue.picker.kind == Kind::Search {
365            drop(glue.rank_worker.take());
366            self.retain_search(glue);
367        } else if let Some(worker) = glue.rank_worker.take() {
368            if let Err(error) = worker.retire(glue.picker) {
369                self.message = format!("picker cleanup failed: {error}");
370            }
371        }
372    }
373
374    pub fn picker_open(&self) -> bool {
375        self.picker.is_some()
376    }
377
378    /// Cancel/forget every preview this picker instance owns. Running
379    /// requests are cancelled; Failed/Cancelled loads and their blank
380    /// cache entries are removed so an explicit reopen retries; Ready
381    /// bytes stay within the cache bound but are not current in a new picker.
382    fn revoke_picker_previews(&mut self, picker: PickerId) {
383        let mut cancelled = Vec::new();
384        let mut forgotten = Vec::new();
385        self.preview_loads.retain(|path, load| match load {
386            Load::Running(ticket) if ticket.key.picker == picker => {
387                cancelled.push(ticket.request);
388                false
389            }
390            Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
391                forgotten.push(path.clone());
392                false
393            }
394            _ => true,
395        });
396        for request in cancelled {
397            if let Some(handle) = self.worker_handles.remove(&request) {
398                handle.cancel(CancelReason::OwnerClosed);
399            }
400        }
401        for path in forgotten {
402            self.previews.remove(&path);
403            self.analysis
404                .forget(super::analysis::AnalysisTarget::Preview(path));
405        }
406    }
407
408    pub(crate) fn feed_picker(&mut self, key: Key) {
409        let Some(glue) = &mut self.picker else {
410            return;
411        };
412        if key != Key::Enter {
413            glue.accept_when_ranked = false;
414        }
415        let search = glue.picker.kind == Kind::Search;
416        let replace = search && glue.picker.replacement_visible;
417        // the suggestion list owns accept/cancel while open (0051 R02)
418        if glue.suggestions.is_some() {
419            match key {
420                Key::Up => {
421                    let list = glue.suggestions.as_mut().unwrap();
422                    list.selected = list.selected.saturating_sub(1);
423                    return;
424                }
425                Key::Down | Key::Tab => {
426                    let list = glue.suggestions.as_mut().unwrap();
427                    list.selected = (list.selected + 1).min(list.items.len().saturating_sub(1));
428                    return;
429                }
430                Key::Enter => {
431                    self.accept_suggestion();
432                    return;
433                }
434                Key::Esc => {
435                    glue.suggestions = None;
436                    return;
437                }
438                _ => {
439                    glue.suggestions = None;
440                }
441            }
442        }
443        match key {
444            Key::CtrlSpace => {
445                self.open_suggestions();
446            }
447            Key::Esc => {
448                if glue.picker.input_normal() {
449                    let origin = glue.search.as_ref().map(|context| context.origin.clone());
450                    self.close_picker();
451                    if let Some(origin) =
452                        origin.filter(|origin| self.docs.get(origin.document).is_some())
453                    {
454                        self.jump_to(origin);
455                    }
456                } else {
457                    glue.picker.enter_normal();
458                }
459            }
460            Key::Enter => self.accept_current_picker(),
461            Key::Tab | Key::Backtab if replace => glue.picker.toggle_field(),
462            // ctrl-o: the listed hits become an editable collection (0044).
463            Key::CtrlO => self.open_collection_from_picker(),
464            Key::CtrlD if search => {
465                if glue.picker.toggle_file_excluded() {
466                    self.search_intent_changed();
467                } else {
468                    self.message = "no source match selected".into();
469                }
470            }
471            Key::CtrlX if search => {
472                if glue.picker.toggle_excluded() {
473                    self.search_intent_changed();
474                } else {
475                    self.message = "no source match selected".into();
476                }
477            }
478            Key::CtrlD | Key::CtrlX => {}
479            Key::Backspace => {
480                if glue.picker.input_normal() {
481                    glue.picker.normal_key('h');
482                } else if replace && glue.picker.field == strop_picker::Field::Replace {
483                    glue.picker.pop_replace_char();
484                    self.search_intent_changed();
485                } else {
486                    glue.picker.pop_char();
487                    self.picker_input_changed();
488                }
489            }
490            Key::CtrlL => self.needs_repaint = true,
491            Key::CtrlR if search => self.toggle_search_replacement(),
492            Key::CtrlR | Key::CtrlW => {}
493            Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
494            Key::Up => glue.picker.move_by(-1),
495            Key::Down => glue.picker.move_by(1),
496            Key::Tab => glue.picker.move_by(1),
497            Key::Backtab => glue.picker.move_by(-1),
498            Key::Left => glue.picker.caret_left(),
499            Key::Right => glue.picker.caret_right(),
500            Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
501            Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
502            Key::Char(c) => {
503                if glue.picker.input_normal() {
504                    if glue.picker.normal_key(c) {
505                        if replace && glue.picker.field == strop_picker::Field::Replace {
506                            self.search_intent_changed();
507                        } else {
508                            self.picker_input_changed();
509                        }
510                    }
511                } else if replace && glue.picker.field == strop_picker::Field::Replace {
512                    glue.picker.push_replace_char(c);
513                    self.search_intent_changed();
514                } else {
515                    glue.picker.push_char(c);
516                    self.picker_input_changed();
517                }
518            }
519        }
520    }
521
522    /// Bracketed paste while a picker is open edits the focused field
523    /// (query, replacement or remote address); it never reaches the
524    /// document behind the card. Multi-line payloads are rejected with
525    /// a message — a dropped keystroke with no feedback reads as a
526    /// broken terminal, not as an editor decision.
527    pub(crate) fn paste_picker(&mut self, text: &str) {
528        if text.is_empty() {
529            return;
530        }
531        let Some(glue) = &mut self.picker else {
532            return;
533        };
534        if text.contains(['\r', '\n']) {
535            self.message = "picker input cannot contain a newline".into();
536            return;
537        }
538        if glue.picker.paste(text) {
539            self.picker_input_changed();
540        } else {
541            self.search_intent_changed();
542        }
543    }
544
545    pub(crate) fn accept_current_picker(&mut self) {
546        if self
547            .picker
548            .as_ref()
549            .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
550        {
551            self.accept_remote_address();
552            return;
553        }
554        let Some(glue) = self.picker.as_mut() else {
555            return;
556        };
557        if matches!(glue.picker.kind, Kind::Files | Kind::Search) {
558            if let Some(error) = &glue.picker.error {
559                self.message = error.clone();
560                return;
561            }
562        }
563        let replacing = glue.picker.kind == Kind::Search
564            && glue.picker.replacement_visible
565            && glue.picker.field == strop_picker::Field::Replace;
566        if glue.rank_pending.is_some()
567            || ((replacing || glue.picker.current().is_none()) && glue.picker.streaming)
568        {
569            glue.accept_when_ranked = true;
570            return;
571        }
572        glue.accept_when_ranked = false;
573        if replacing {
574            self.prepare_search_review();
575            return;
576        }
577        let payload = glue.picker.current().map(|item| item.payload.clone());
578        // RemoteHosts: the pinned "Add a host…" row keeps one meaning —
579        // open the address box. Typed filter text comes along as the
580        // draft, so a hostname that matched no listed destination isn't
581        // lost (0.21.0 field report), and typing "Add" can't connect to
582        // a host literally named "add".
583        if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
584        {
585            let draft = {
586                let text = glue.picker.input.text.trim();
587                // Filter text that matches the pinned row's own label was
588                // aimed AT the row ("Add"); only text that matched
589                // nothing — a bare hostname — becomes the address draft.
590                let aimed_at_row = glue
591                    .picker
592                    .current()
593                    .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
594                (!aimed_at_row).then(|| text.to_string())
595            };
596            self.close_picker();
597            self.open_remote_address();
598            if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
599                if let Some(glue) = self.picker.as_mut() {
600                    glue.picker.paste(&draft);
601                }
602            }
603            return;
604        }
605        // TabSize: every row is an IndentChoice; the typed filter text
606        // rides along so the pinned custom row can validate it as a
607        // width (the RemoteHosts draft pattern, 0.21.0).
608        if glue.picker.kind == Kind::TabSize {
609            let draft = glue.picker.input.text.trim().to_string();
610            let Some(Payload::IndentChoice(choice)) = payload else {
611                self.message = "no matching entries".into();
612                return;
613            };
614            let Some(document) = glue.indent_target else {
615                self.message = "indentation selector lost its source — reopen :tab-size".into();
616                return;
617            };
618            self.close_picker();
619            self.accept_indent_choice(document, choice, &draft);
620            return;
621        }
622        let Some(payload) = payload else {
623            self.message = "no matching entries".into();
624            return;
625        };
626        if glue.picker.kind == Kind::Search {
627            self.open_search_hit(payload);
628            return;
629        }
630        let context = glue.lsp_context;
631        self.close_picker();
632        self.accept_picker(payload, context);
633    }
634
635    pub(crate) fn finish_pending_picker_accept(&mut self) {
636        if self.picker.as_ref().is_some_and(|glue| {
637            glue.accept_when_ranked
638                && glue.rank_pending.is_none()
639                && (!(glue.picker.kind == Kind::Search
640                    && glue.picker.replacement_visible
641                    && glue.picker.field == strop_picker::Field::Replace)
642                    || !glue.picker.streaming)
643        }) {
644            self.accept_current_picker();
645        }
646    }
647}
648
649pub struct PreviewEntry {
650    pub rope: ropey::Rope,
651}
652
653pub enum PreviewSource {
654    Buffer(strop_core::id::DocumentId),
655    Cached(PathBuf),
656    Loading,
657    Failed(String),
658    Cancelled(CancelReason),
659}
660
661pub type Previews = HashMap<PathBuf, PreviewEntry>;
662
663/// One jumplist row; dead documents drop out (0047 §2). The payload
664/// stays a plain destination — accepting a menu entry is a NEW jump
665/// landing (0051 §7), not a ctrl-o view restore.
666fn jump_row(editor: &Editor, record: &super::jumps::JumpRecord, marker: &str) -> Option<Item> {
667    let doc = editor.docs.get(record.document)?;
668    let name = doc
669        .buf
670        .path
671        .as_ref()
672        .map(|path| path.to_string_lossy().into_owned())
673        .unwrap_or_else(|| "[scratch]".into());
674    let line = doc.buf.line_of(record.offset.min(doc.buf.len_bytes()));
675    let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
676    Some(Item {
677        badge: None,
678        text: format!("{marker}{name}:{}  {text}", line + 1),
679        payload: Payload::Jump {
680            document: record.document,
681            offset: record.offset,
682        },
683    })
684}