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