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                        text: name,
182                        payload: Payload::Buffer(i),
183                    }
184                })
185                .collect(),
186            // Grep/Replace stream only once input registers a request;
187            // Files launches its walk right after install.
188            Kind::Files
189            | Kind::Grep
190            | Kind::Replace
191            | Kind::RemoteHosts
192            | Kind::RemoteAddress
193            | Kind::CodeActions
194            | Kind::Containers => vec![],
195            Kind::Jumps => unreachable!("the jumplist builds its own items"),
196            Kind::Symbols => vec![],
197            Kind::Diagnostics | Kind::Locations => {
198                unreachable!("location lists use PickerGlue::diagnostics")
199            }
200        };
201        self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
202        if kind == Kind::Files {
203            self.launch_files_request();
204        }
205    }
206
207    /// The jumplist as a menu (0047 §2): past newest-first, the current
208    /// position marked, then the future; dead documents are filtered.
209    pub(crate) fn open_jumps_picker(&mut self) {
210        let mut items = Vec::new();
211        for &entry in self.jumplist_past.iter().rev() {
212            items.extend(jump_row(self, entry, "  "));
213        }
214        items.extend(jump_row(self, (self.current(), self.head()), "> "));
215        for &entry in self.jumplist_future.iter().rev() {
216            items.extend(jump_row(self, entry, "  "));
217        }
218        self.set_picker(PickerGlue::diagnostics(Picker::new(
219            Kind::Jumps,
220            items,
221            false,
222        )));
223    }
224
225    /// The files walk as an owned request. Registration precedes
226    /// launch: the worker can only post onto its stream, and nothing
227    /// reaches the model until the ticket is the active owner. Replay
228    /// mode stops after registration (Main's service seam).
229    fn launch_files_request(&mut self) {
230        let Some(picker) = self.picker.as_ref().map(|glue| glue.id) else {
231            return;
232        };
233        let request = match self.worker_ids.allocate() {
234            Ok(request) => request,
235            Err(error) => {
236                self.message = error.message;
237                return;
238            }
239        };
240        let ticket = Ticket {
241            request,
242            key: PickerKey {
243                picker,
244                cwd: self.cwd.clone(),
245            },
246        };
247        if let Some(glue) = self.picker.as_mut() {
248            glue.active = Some(ticket.clone());
249            glue.picker.streaming = true;
250        }
251        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
252            serde_json::json!({
253                "service":"picker","source":"files","id":picker.0.get(),
254                "request":request.get(),"cwd":self.cwd.to_string_lossy(),
255            })
256        });
257        match self
258            .tape
259            .request("picker-files", &serde_json::json!({"ticket":ticket}))
260        {
261            Ok(false) => return,
262            Ok(true) => {}
263            Err(error) => {
264                self.handle_picker_event(PickerEvent {
265                    ticket,
266                    msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
267                        strop_core::worker::FailureKind::Protocol,
268                        error.to_string(),
269                    )),
270                });
271                return;
272            }
273        }
274        let (tx, rx) = channel();
275        let worker = spawn_files(self.cwd.clone(), tx);
276        if let Some(glue) = self.picker.as_mut() {
277            glue.worker = Some(PickerWorker::Files(worker));
278        }
279        self.attach_picker_stream(ticket, rx);
280    }
281
282    /// Hand a launched request's stream to the app event loop (TUI) or
283    /// keep it for the headless drain.
284    fn attach_picker_stream(&mut self, ticket: Ticket<PickerKey>, rx: Receiver<PickerMsg>) {
285        let Some(app_tx) = self.app_tx.clone() else {
286            if let Some(glue) = self.picker.as_mut() {
287                glue.rx = Some((ticket, rx));
288            }
289            return;
290        };
291        if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), app_tx) {
292            // the bridge thread could not start: settle the request now
293            self.handle_picker_event(PickerEvent {
294                ticket,
295                msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
296                    strop_core::worker::FailureKind::ThreadStart,
297                    format!("picker bridge: {error}"),
298                )),
299            });
300        }
301    }
302
303    /// Connect-time: hand any already-registered headless stream to
304    /// the app channel (normally requests attach at launch).
305    pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
306        if let Some(glue) = &mut self.picker {
307            if let Some((ticket, rx)) = glue.rx.take() {
308                let _ = drain::forward_picker_stream(rx, ticket, tx.clone());
309            }
310        }
311    }
312
313    /// Close the picker: revoke its active request, stop its worker,
314    /// and cancel/forget the previews it owns (failed reads become
315    /// retryable on reopen; successful caches survive).
316    pub fn close_picker(&mut self) {
317        let Some(mut glue) = self.picker.take() else {
318            return;
319        };
320        glue.revoke(CancelReason::OwnerClosed);
321        self.revoke_remote_chooser(glue.id);
322        self.revoke_picker_previews(glue.id);
323        if glue.rank_alive {
324            self.picker_ranking.retiring.insert(glue.id);
325        }
326        if let Some(worker) = glue.rank_worker.take() {
327            if let Err(error) = worker.retire(glue.picker) {
328                self.message = format!("picker cleanup failed: {error}");
329            }
330        }
331    }
332
333    pub fn picker_open(&self) -> bool {
334        self.picker.is_some()
335    }
336
337    /// Cancel/forget every preview this picker instance owns. Running
338    /// requests are cancelled; Failed/Cancelled loads and their blank
339    /// cache entries are removed so an explicit reopen retries; Ready
340    /// caches stay (a successful read is still a successful read).
341    fn revoke_picker_previews(&mut self, picker: PickerId) {
342        let mut cancelled = Vec::new();
343        let mut forgotten = Vec::new();
344        self.preview_loads.retain(|path, load| match load {
345            Load::Running(ticket) if ticket.key.picker == picker => {
346                cancelled.push(ticket.request);
347                false
348            }
349            Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
350                forgotten.push(path.clone());
351                false
352            }
353            _ => true,
354        });
355        for request in cancelled {
356            if let Some(handle) = self.worker_handles.remove(&request) {
357                handle.cancel(CancelReason::OwnerClosed);
358            }
359        }
360        for path in forgotten {
361            self.previews.remove(&path);
362            self.analysis
363                .forget(super::analysis::AnalysisTarget::Preview(path));
364        }
365    }
366
367    pub(crate) fn feed_picker(&mut self, key: Key) {
368        let Some(glue) = &mut self.picker else {
369            return;
370        };
371        if key != Key::Enter {
372            glue.accept_when_ranked = false;
373        }
374        let replace = glue.picker.kind == Kind::Replace;
375        match key {
376            Key::Esc => {
377                if glue.picker.input_normal() {
378                    self.close_picker();
379                } else {
380                    glue.picker.enter_normal();
381                }
382            }
383            Key::Enter => self.accept_current_picker(),
384            Key::Tab | Key::Backtab if replace => glue.picker.toggle_field(),
385            // ctrl-o: the listed hits become an editable collection (0044).
386            Key::CtrlO => self.open_collection_from_picker(),
387            Key::CtrlD if replace => glue.picker.toggle_file_excluded(),
388            Key::CtrlD => {}
389            Key::CtrlX => {}
390            Key::Backspace => {
391                if glue.picker.input_normal() {
392                    glue.picker.normal_key('h');
393                } else if replace && glue.picker.field == strop_picker::Field::Replace {
394                    glue.picker.pop_replace_char();
395                } else {
396                    glue.picker.pop_char();
397                    self.picker_input_changed();
398                }
399            }
400            Key::CtrlL => self.needs_repaint = true,
401            Key::CtrlR | Key::CtrlW => {}
402            Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
403            Key::Up => glue.picker.move_by(-1),
404            Key::Down => glue.picker.move_by(1),
405            Key::Tab => glue.picker.move_by(1),
406            Key::Backtab => glue.picker.move_by(-1),
407            Key::Left => glue.picker.caret_left(),
408            Key::Right => glue.picker.caret_right(),
409            Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
410            Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
411            Key::Char(c) => {
412                if glue.picker.input_normal() {
413                    if glue.picker.normal_key(c) {
414                        self.picker_input_changed();
415                    }
416                } else if replace && glue.picker.field == strop_picker::Field::Replace {
417                    glue.picker.push_replace_char(c);
418                } else {
419                    glue.picker.push_char(c);
420                    self.picker_input_changed();
421                }
422            }
423        }
424    }
425
426    /// Bracketed paste while a picker is open edits the focused field
427    /// (query, replacement or remote address); it never reaches the
428    /// document behind the card. Multi-line payloads are rejected with
429    /// a message — a dropped keystroke with no feedback reads as a
430    /// broken terminal, not as an editor decision.
431    pub(crate) fn paste_picker(&mut self, text: &str) {
432        let Some(glue) = &mut self.picker else {
433            return;
434        };
435        if text.contains(['\r', '\n']) {
436            self.message = "picker input cannot contain a newline".into();
437            return;
438        }
439        if glue.picker.paste(text) {
440            self.picker_input_changed();
441        }
442    }
443
444    pub(crate) fn accept_current_picker(&mut self) {
445        if self
446            .picker
447            .as_ref()
448            .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
449        {
450            self.accept_remote_address();
451            return;
452        }
453        let Some(glue) = self.picker.as_mut() else {
454            return;
455        };
456        let replacing = glue.picker.kind == Kind::Replace;
457        if (replacing || glue.picker.current().is_none())
458            && (glue.rank_pending.is_some() || glue.picker.streaming)
459        {
460            glue.accept_when_ranked = true;
461            return;
462        }
463        glue.accept_when_ranked = false;
464        if replacing {
465            self.apply_replace();
466            return;
467        }
468        let payload = glue.picker.current().map(|item| item.payload.clone());
469        // RemoteHosts: the pinned "Add a host…" row keeps one meaning —
470        // open the address box. Typed filter text comes along as the
471        // draft, so a hostname that matched no listed destination isn't
472        // lost (0.21.0 field report), and typing "Add" can't connect to
473        // a host literally named "add".
474        if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
475        {
476            let draft = {
477                let text = glue.picker.input.text.trim();
478                // Filter text that matches the pinned row's own label was
479                // aimed AT the row ("Add"); only text that matched
480                // nothing — a bare hostname — becomes the address draft.
481                let aimed_at_row = glue
482                    .picker
483                    .current()
484                    .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
485                (!aimed_at_row).then(|| text.to_string())
486            };
487            self.close_picker();
488            self.open_remote_address();
489            if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
490                if let Some(glue) = self.picker.as_mut() {
491                    glue.picker.paste(&draft);
492                }
493            }
494            return;
495        }
496        let Some(payload) = payload else {
497            self.message = "no matching entries".into();
498            return;
499        };
500        let context = glue.lsp_context;
501        self.close_picker();
502        self.accept_picker(payload, context);
503    }
504
505    pub(crate) fn finish_pending_picker_accept(&mut self) {
506        if self.picker.as_ref().is_some_and(|glue| {
507            glue.accept_when_ranked
508                && glue.rank_pending.is_none()
509                && (glue.picker.kind != Kind::Replace || !glue.picker.streaming)
510        }) {
511            self.accept_current_picker();
512        }
513    }
514
515    /// Grep/Replace: every input change is a new owned request — the
516    /// previous one is superseded, its items/rows/exclusions cleared,
517    /// and a fresh ticket + worker launched. Other kinds just refilter.
518    fn picker_input_changed(&mut self) {
519        let (query, picker) = {
520            let Some(glue) = &mut self.picker else {
521                return;
522            };
523            if glue.picker.kind == Kind::RemoteAddress {
524                glue.picker.error = None;
525                return;
526            }
527            if !matches!(glue.picker.kind, Kind::Grep | Kind::Replace) {
528                self.request_picker_ranking();
529                return;
530            }
531            let query = glue.picker.input.text.clone();
532            let picker = glue.id;
533            glue.revoke(CancelReason::Superseded);
534            glue.picker.error = None;
535            glue.picker.clear_items();
536            glue.ranked_query = None;
537            glue.rank_pending = None;
538            (query, picker)
539        };
540        let request = match self.worker_ids.allocate() {
541            Ok(request) => request,
542            Err(error) => {
543                // no identity: settle as not streaming; the next
544                // keystroke retries with a fresh allocation
545                if let Some(glue) = self.picker.as_mut() {
546                    glue.picker.streaming = false;
547                }
548                self.message = error.message;
549                return;
550            }
551        };
552        let ticket = Ticket {
553            request,
554            key: PickerKey {
555                picker,
556                cwd: self.cwd.clone(),
557            },
558        };
559        // registration precedes launch (replay stops here)
560        if let Some(glue) = self.picker.as_mut() {
561            glue.active = Some(ticket.clone());
562            glue.picker.streaming = true;
563        }
564        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
565            serde_json::json!({
566                "service":"picker","source":"grep","id":picker.0.get(),
567                "request":request.get(),"query":query,"streaming":true,
568            })
569        });
570        match self.tape.request(
571            "picker-grep",
572            &serde_json::json!({"ticket":ticket,"query":query}),
573        ) {
574            Ok(false) => return,
575            Ok(true) => {}
576            Err(error) => {
577                self.handle_picker_event(PickerEvent {
578                    ticket,
579                    msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
580                        strop_core::worker::FailureKind::Protocol,
581                        error.to_string(),
582                    )),
583                });
584                return;
585            }
586        }
587        let (tx, rx) = channel();
588        let worker = GrepWorker::spawn(&query, &self.cwd, tx);
589        if let Some(glue) = self.picker.as_mut() {
590            glue.worker = Some(PickerWorker::Grep(worker));
591        }
592        self.attach_picker_stream(ticket, rx);
593    }
594}
595
596pub struct PreviewEntry {
597    pub rope: ropey::Rope,
598}
599
600pub enum PreviewSource {
601    Buffer(strop_core::id::DocumentId),
602    Cached(PathBuf),
603    Loading,
604    Failed(String),
605    Cancelled(CancelReason),
606}
607
608pub type Previews = HashMap<PathBuf, PreviewEntry>;
609
610/// One jumplist row; dead documents drop out (0047 §2).
611fn jump_row(
612    editor: &Editor,
613    (document, offset): (strop_core::id::DocumentId, usize),
614    marker: &str,
615) -> Option<Item> {
616    let doc = editor.docs.get(document)?;
617    let name = doc
618        .buf
619        .path
620        .as_ref()
621        .map(|path| path.to_string_lossy().into_owned())
622        .unwrap_or_else(|| "[scratch]".into());
623    let line = doc.buf.line_of(offset);
624    let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
625    Some(Item {
626        text: format!("{marker}{name}:{}  {text}", line + 1),
627        payload: Payload::Jump { document, offset },
628    })
629}