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