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