Skip to main content

strop_engine/editor/picker/
search.rs

1//! One bounded retained Search investigation, independent of its visible card.
2use super::*;
3use strop_workspace::{Filesystem, ResourceLocation};
4
5#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6pub struct SearchScope {
7    pub root: ResourceLocation,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub(crate) struct SearchStamp {
12    pub session: WorkerId,
13    pub dataset: u64,
14    pub intent: u64,
15}
16
17pub(crate) struct SearchContext {
18    pub scope: SearchScope,
19    pub stamp: SearchStamp,
20    pub origin: super::super::jumps::JumpRecord,
21    pub refreshing: bool,
22    pub refresh_requested: bool,
23    pub policy: (bool, bool),
24    restore: Option<RestoreView>,
25}
26
27impl SearchContext {
28    pub(super) fn discard_view_restore(&mut self) {
29        self.restore = None;
30    }
31    pub(super) fn begin_view_restore(&mut self) {
32        if let Some(restore) = self.restore.as_mut() {
33            restore.selected_item = None;
34            restore.top_item = None;
35        }
36    }
37}
38struct RestoreView {
39    selected: Option<HitWitness>,
40    top: Option<HitWitness>,
41    selected_item: Option<usize>,
42    top_item: Option<usize>,
43}
44struct HitWitness {
45    path: PathBuf,
46    line: usize,
47    column: usize,
48    length: usize,
49    text: std::sync::Arc<str>,
50}
51impl HitWitness {
52    fn from_item(item: &Item) -> Option<Self> {
53        let Payload::Grep {
54            path,
55            line,
56            col,
57            match_len,
58            line_text,
59        } = &item.payload
60        else {
61            return None;
62        };
63        Some(Self {
64            path: path.clone(),
65            line: *line,
66            column: *col,
67            length: *match_len,
68            text: line_text.clone(),
69        })
70    }
71    fn matches(&self, item: &Item) -> bool {
72        matches!(&item.payload, Payload::Grep {path,line,col,match_len,line_text}
73            if self.path == *path && self.line == *line && self.column == *col
74                && self.length == *match_len && self.text == *line_text)
75    }
76}
77
78impl Editor {
79    pub fn search_scope(&self) -> Option<&SearchScope> {
80        self.picker
81            .as_ref()?
82            .search
83            .as_ref()
84            .map(|context| &context.scope)
85    }
86
87    pub fn picker_path(&self, path: &std::path::Path) -> PathBuf {
88        self.search_scope()
89            .map_or(&self.cwd, |scope| &scope.root.path)
90            .join(path)
91    }
92
93    pub(crate) fn search_stamp(&self, session: WorkerId) -> Option<SearchStamp> {
94        self.picker
95            .iter()
96            .chain(self.retained_search.iter())
97            .filter_map(|glue| glue.search.as_ref())
98            .find(|context| context.stamp.session == session)
99            .map(|context| context.stamp)
100    }
101    pub(super) fn open_search_hit(&mut self, payload: Payload) {
102        let Payload::Grep {
103            path,
104            line,
105            col,
106            match_len,
107            line_text,
108        } = payload
109        else {
110            self.message = "Search result has no supported source witness".into();
111            return;
112        };
113        let Some(scope) = self.search_scope() else {
114            self.message = "Search has no captured workspace scope".into();
115            return;
116        };
117        let path = scope.root.path.join(path);
118        let hit = ReplacementHit {
119            line,
120            col,
121            match_len,
122            text: line_text,
123        };
124        self.push_jump();
125        self.close_picker();
126        self.request_open(path, super::super::io::OpenIntent::SearchHit(hit));
127    }
128
129    pub(super) fn new_search_context(
130        &mut self,
131        scope: SearchScope,
132    ) -> Result<SearchContext, String> {
133        let session = self.worker_ids.allocate().map_err(|error| error.message)?;
134        Ok(SearchContext {
135            scope,
136            stamp: SearchStamp {
137                session,
138                dataset: 0,
139                intent: 0,
140            },
141            origin: self.jump_record(),
142            refreshing: false,
143            refresh_requested: false,
144            policy: (
145                self.config.search_show_hidden,
146                self.config.search_respect_ignore,
147            ),
148            restore: None,
149        })
150    }
151
152    pub fn open_search(&mut self, replacement: bool) {
153        let scope = self
154            .picker
155            .iter()
156            .chain(self.retained_search.iter())
157            .find_map(|glue| glue.search.as_ref().map(|context| context.scope.clone()))
158            .unwrap_or_else(|| SearchScope {
159                root: ResourceLocation::local(self.cwd.clone()),
160            });
161        self.open_search_in(scope, replacement);
162    }
163
164    pub fn open_search_in(&mut self, scope: SearchScope, replacement: bool) {
165        if scope.root.filesystem != Filesystem::Local || !scope.root.path.is_absolute() {
166            self.message = "Search currently requires an absolute local-workspace scope; no filesystem fallback".into();
167            return;
168        }
169        self.cancel_open(CancelReason::Superseded);
170        if let Some(glue) = self.picker.as_mut().filter(|glue| {
171            glue.search
172                .as_ref()
173                .is_some_and(|context| context.scope == scope)
174        }) {
175            if replacement {
176                glue.picker.replacement_visible = true;
177                if !glue.picker.input.text.is_empty() {
178                    glue.picker.field = strop_picker::Field::Replace;
179                }
180                glue.suggestions = None;
181                glue.accept_when_ranked = false;
182            }
183            return;
184        }
185        self.close_picker();
186        let mut glue = match self.retained_search.take() {
187            Some(mut glue)
188                if glue
189                    .search
190                    .as_ref()
191                    .is_some_and(|context| context.scope == scope) =>
192            {
193                if let Some(context) = glue.search.as_mut() {
194                    context.origin = self.jump_record();
195                    context.refresh_requested = true;
196                }
197                glue
198            }
199            old => {
200                if let Some(old) = old {
201                    self.retire_picker_model(old.picker);
202                }
203                let context = match self.new_search_context(scope) {
204                    Ok(context) => context,
205                    Err(error) => {
206                        self.message = error;
207                        return;
208                    }
209                };
210                let mut glue =
211                    PickerGlue::diagnostics(Picker::search(Vec::new(), false, replacement));
212                glue.search = Some(context);
213                glue
214            }
215        };
216        if replacement {
217            glue.picker.replacement_visible = true;
218            glue.picker.field = if glue.picker.input.text.is_empty() {
219                strop_picker::Field::Search
220            } else {
221                strop_picker::Field::Replace
222            };
223        }
224        self.set_picker(glue);
225        // Refresh has query ownership even when With retains keyboard focus.
226        self.restart_search_query();
227    }
228
229    pub(super) fn retain_search(&mut self, mut glue: PickerGlue) {
230        if let Some(context) = glue.search.as_mut() {
231            if glue.picker.current().is_some() || context.restore.is_none() {
232                context.restore = Some(RestoreView {
233                    selected: glue.picker.current().and_then(HitWitness::from_item),
234                    top: glue
235                        .picker
236                        .rows
237                        .get(glue.picker.scroll_top)
238                        .and_then(|row| glue.picker.items.get(row.item))
239                        .and_then(HitWitness::from_item),
240                    selected_item: None,
241                    top_item: None,
242                });
243            }
244        }
245        glue.rank_pending = None;
246        glue.rank_dirty = false;
247        glue.rank_alive = false;
248        glue.accept_when_ranked = false;
249        glue.suggestions = None;
250        if let Some(old) = self.retained_search.replace(glue) {
251            self.retire_picker_model(old.picker);
252        }
253    }
254
255    fn retire_picker_model(&mut self, picker: Picker) {
256        let request = match self.worker_ids.allocate() {
257            Ok(request) => request,
258            Err(error) => {
259                self.message = error.message;
260                return;
261            }
262        };
263        let id = PickerId(request);
264        self.picker_ranking.retiring.insert(id);
265        match self.tape.request("picker.reclaim", &id) {
266            Ok(false) => return,
267            Err(error) => {
268                self.picker_ranking.retiring.remove(&id);
269                self.message = format!("search retirement failed: {error}");
270                return;
271            }
272            Ok(true) => {}
273        }
274        let tx = self.picker_ranking.tx.clone();
275        match strop_picker::RankingWorker::<ranking::Key>::start(move |update| {
276            tx.send(ranking::Event { picker: id, update }).is_ok()
277        }) {
278            Ok(worker) => {
279                if let Err(error) = worker.retire(picker) {
280                    self.message = format!("search retirement failed: {error}");
281                }
282            }
283            Err(error) => {
284                self.picker_ranking.retiring.remove(&id);
285                self.message = format!("search retirement failed: {error}");
286            }
287        }
288    }
289
290    pub(super) fn search_intent_changed(&mut self) {
291        let session = if let Some(glue) = self.picker.as_mut() {
292            glue.accept_when_ranked = false;
293            let Some(context) = glue.search.as_mut() else {
294                return;
295            };
296            let Some(next) = context.stamp.intent.checked_add(1) else {
297                glue.picker.error = Some("search edit generation exhausted".into());
298                return;
299            };
300            context.stamp.intent = next;
301            context.stamp.session
302        } else {
303            return;
304        };
305        self.invalidate_search_review(session);
306    }
307
308    pub(super) fn toggle_search_replacement(&mut self) {
309        if let Some(glue) = self
310            .picker
311            .as_mut()
312            .filter(|glue| glue.picker.kind == Kind::Search)
313        {
314            glue.picker.toggle_replacement();
315            glue.accept_when_ranked = false;
316            glue.suggestions = None;
317        }
318    }
319
320    pub(super) fn observe_search_items(&mut self, items: &[Item]) {
321        let Some(glue) = self.picker.as_mut() else {
322            return;
323        };
324        let Some(restore) = glue
325            .search
326            .as_mut()
327            .and_then(|context| context.restore.as_mut())
328        else {
329            return;
330        };
331        let base = glue.picker.items.len();
332        for (offset, item) in items.iter().enumerate() {
333            if restore
334                .selected
335                .as_ref()
336                .is_some_and(|wanted| wanted.matches(item))
337            {
338                restore.selected_item = Some(base + offset);
339            }
340            if restore
341                .top
342                .as_ref()
343                .is_some_and(|wanted| wanted.matches(item))
344            {
345                restore.top_item = Some(base + offset);
346            }
347        }
348    }
349
350    pub(super) fn finish_search_refresh(&mut self) {
351        let Some(glue) = self.picker.as_mut().filter(|glue| {
352            glue.picker.kind == Kind::Search
353                && !glue.picker.streaming
354                && glue.active.is_none()
355                && glue.rank_pending.is_none()
356                && glue.picker.error.is_none()
357        }) else {
358            return;
359        };
360        let Some(context) = glue.search.as_mut().filter(|context| context.refreshing) else {
361            return;
362        };
363        context.refreshing = false;
364        let lost = glue.picker.finish_workset_refresh();
365        let mut lost_selection = false;
366        if let Some(restore) = context.restore.take() {
367            if let Some(index) = restore.selected_item {
368                glue.picker.selected = index;
369            } else {
370                lost_selection = restore.selected.is_some();
371            }
372            if let Some(index) = restore.top_item {
373                glue.picker.scroll_top = index;
374            }
375        }
376        if lost > 0 || lost_selection {
377            glue.picker.warning = Some(format!(
378                "refresh: {lost} workset decision(s) no longer matched{}",
379                if lost_selection {
380                    "; selected hit changed"
381                } else {
382                    ""
383                }
384            ));
385        }
386    }
387
388    pub(crate) fn resume_search_after_review(&mut self, stamp: SearchStamp) {
389        let Some(glue) = self.retained_search.as_ref().filter(|glue| {
390            glue.search
391                .as_ref()
392                .is_some_and(|context| context.stamp.session == stamp.session)
393        }) else {
394            self.message = "the original Search investigation is no longer retained".into();
395            return;
396        };
397        let Some(context) = glue.search.as_ref() else {
398            return;
399        };
400        let scope = context.scope.clone();
401        let origin = context.origin.clone();
402        if self.docs.get(origin.document).is_some() {
403            self.jump_to(origin);
404        }
405        self.open_search_in(scope, false);
406    }
407}