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: ResourceLocation,
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            location: 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 {location: 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            location,
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        if location.filesystem != scope.root.filesystem
118            || !location.path.starts_with(&scope.root.path)
119        {
120            self.message = "Search result escaped its captured namespace/root".into();
121            return;
122        }
123        let target = match crate::files::FileTarget::from_location(&location) {
124            Ok(target) => target,
125            Err(error) => {
126                self.message = error.to_string();
127                return;
128            }
129        };
130        let hit = ReplacementHit {
131            line,
132            col,
133            match_len,
134            text: line_text,
135        };
136        self.push_jump();
137        self.close_picker();
138        self.request_target(target, super::super::io::OpenIntent::SearchHit(hit));
139    }
140
141    pub(super) fn new_search_context(
142        &mut self,
143        scope: SearchScope,
144    ) -> Result<SearchContext, String> {
145        let session = self.worker_ids.allocate().map_err(|error| error.message)?;
146        Ok(SearchContext {
147            scope,
148            stamp: SearchStamp {
149                session,
150                dataset: 0,
151                intent: 0,
152            },
153            origin: self.jump_record(),
154            refreshing: false,
155            refresh_requested: false,
156            policy: (
157                self.config.search_show_hidden,
158                self.config.search_respect_ignore,
159            ),
160            restore: None,
161        })
162    }
163
164    pub fn open_search(&mut self, replacement: bool) {
165        let scope = self
166            .picker
167            .iter()
168            .chain(self.retained_search.iter())
169            .find_map(|glue| glue.search.as_ref().map(|context| context.scope.clone()))
170            .unwrap_or_else(|| SearchScope {
171                root: ResourceLocation::local(self.cwd.clone()),
172            });
173        self.open_search_in(scope, replacement);
174    }
175
176    pub fn open_search_in(&mut self, scope: SearchScope, replacement: bool) {
177        if matches!(scope.root.filesystem, Filesystem::Container(_))
178            || !scope.root.path.is_absolute()
179        {
180            self.message =
181                "Search requires an absolute local or capable SSH scope; no filesystem fallback"
182                    .into();
183            return;
184        }
185        if replacement && scope.root.filesystem != Filesystem::Local {
186            self.message = "SSH Search is read-only; With and Review are unavailable".into();
187            return;
188        }
189        self.cancel_open(CancelReason::Superseded);
190        if let Some(glue) = self.picker.as_mut().filter(|glue| {
191            glue.search
192                .as_ref()
193                .is_some_and(|context| context.scope == scope)
194        }) {
195            if replacement {
196                glue.picker.replacement_visible = true;
197                if !glue.picker.input.text.is_empty() {
198                    glue.picker.field = strop_picker::Field::Replace;
199                }
200                glue.suggestions = None;
201                glue.accept_when_ranked = false;
202            }
203            return;
204        }
205        self.close_picker();
206        let mut glue = match self.retained_search.take() {
207            Some(mut glue)
208                if glue
209                    .search
210                    .as_ref()
211                    .is_some_and(|context| context.scope == scope) =>
212            {
213                if let Some(context) = glue.search.as_mut() {
214                    context.origin = self.jump_record();
215                    context.refresh_requested = true;
216                }
217                glue
218            }
219            old => {
220                if let Some(old) = old {
221                    self.retire_picker_model(old.picker);
222                }
223                let context = match self.new_search_context(scope) {
224                    Ok(context) => context,
225                    Err(error) => {
226                        self.message = error;
227                        return;
228                    }
229                };
230                let mut glue =
231                    PickerGlue::diagnostics(Picker::search(Vec::new(), false, replacement));
232                glue.search = Some(context);
233                glue
234            }
235        };
236        if replacement {
237            glue.picker.replacement_visible = true;
238            glue.picker.field = if glue.picker.input.text.is_empty() {
239                strop_picker::Field::Search
240            } else {
241                strop_picker::Field::Replace
242            };
243        }
244        self.set_picker(glue);
245        // Refresh has query ownership even when With retains keyboard focus.
246        self.restart_search_query();
247    }
248
249    pub(super) fn retain_search(&mut self, mut glue: PickerGlue) {
250        if let Some(context) = glue.search.as_mut() {
251            if glue.picker.current().is_some() || context.restore.is_none() {
252                context.restore = Some(RestoreView {
253                    selected: glue.picker.current().and_then(HitWitness::from_item),
254                    top: glue
255                        .picker
256                        .rows
257                        .get(glue.picker.scroll_top)
258                        .and_then(|row| glue.picker.items.get(row.item))
259                        .and_then(HitWitness::from_item),
260                    selected_item: None,
261                    top_item: None,
262                });
263            }
264        }
265        glue.rank_pending = None;
266        glue.rank_dirty = false;
267        glue.rank_alive = false;
268        glue.accept_when_ranked = false;
269        glue.suggestions = None;
270        if let Some(old) = self.retained_search.replace(glue) {
271            self.retire_picker_model(old.picker);
272        }
273    }
274
275    fn retire_picker_model(&mut self, picker: Picker) {
276        let request = match self.worker_ids.allocate() {
277            Ok(request) => request,
278            Err(error) => {
279                self.message = error.message;
280                return;
281            }
282        };
283        let id = PickerId(request);
284        self.picker_ranking.retiring.insert(id);
285        match self.tape.request("picker.reclaim", &id) {
286            Ok(false) => return,
287            Err(error) => {
288                self.picker_ranking.retiring.remove(&id);
289                self.message = format!("search retirement failed: {error}");
290                return;
291            }
292            Ok(true) => {}
293        }
294        let tx = self.picker_ranking.tx.clone();
295        match strop_picker::RankingWorker::<ranking::Key>::start(move |update| {
296            tx.send(ranking::Event { picker: id, update }).is_ok()
297        }) {
298            Ok(worker) => {
299                if let Err(error) = worker.retire(picker) {
300                    self.message = format!("search retirement failed: {error}");
301                }
302            }
303            Err(error) => {
304                self.picker_ranking.retiring.remove(&id);
305                self.message = format!("search retirement failed: {error}");
306            }
307        }
308    }
309
310    pub(super) fn search_intent_changed(&mut self) {
311        let session = if let Some(glue) = self.picker.as_mut() {
312            glue.accept_when_ranked = false;
313            let Some(context) = glue.search.as_mut() else {
314                return;
315            };
316            let Some(next) = context.stamp.intent.checked_add(1) else {
317                glue.picker.error = Some("search edit generation exhausted".into());
318                return;
319            };
320            context.stamp.intent = next;
321            context.stamp.session
322        } else {
323            return;
324        };
325        self.invalidate_search_review(session);
326    }
327
328    pub(super) fn toggle_search_replacement(&mut self) {
329        if let Some(glue) = self
330            .picker
331            .as_mut()
332            .filter(|glue| glue.picker.kind == Kind::Search)
333        {
334            if glue
335                .search
336                .as_ref()
337                .is_some_and(|context| context.scope.root.filesystem != Filesystem::Local)
338            {
339                self.message = "SSH Search is read-only; With and Review are unavailable".into();
340                return;
341            }
342            glue.picker.toggle_replacement();
343            glue.accept_when_ranked = false;
344            glue.suggestions = None;
345        }
346    }
347
348    pub(super) fn observe_search_items(&mut self, items: &[Item]) {
349        let Some(glue) = self.picker.as_mut() else {
350            return;
351        };
352        let Some(restore) = glue
353            .search
354            .as_mut()
355            .and_then(|context| context.restore.as_mut())
356        else {
357            return;
358        };
359        let base = glue.picker.items.len();
360        for (offset, item) in items.iter().enumerate() {
361            if restore
362                .selected
363                .as_ref()
364                .is_some_and(|wanted| wanted.matches(item))
365            {
366                restore.selected_item = Some(base + offset);
367            }
368            if restore
369                .top
370                .as_ref()
371                .is_some_and(|wanted| wanted.matches(item))
372            {
373                restore.top_item = Some(base + offset);
374            }
375        }
376    }
377
378    pub(super) fn finish_search_refresh(&mut self) {
379        let Some(glue) = self.picker.as_mut().filter(|glue| {
380            glue.picker.kind == Kind::Search
381                && !glue.picker.streaming
382                && glue.active.is_none()
383                && glue.rank_pending.is_none()
384                && glue.picker.error.is_none()
385        }) else {
386            return;
387        };
388        let Some(context) = glue.search.as_mut().filter(|context| context.refreshing) else {
389            return;
390        };
391        context.refreshing = false;
392        let lost = glue.picker.finish_workset_refresh();
393        let mut lost_selection = false;
394        if let Some(restore) = context.restore.take() {
395            if let Some(index) = restore.selected_item {
396                glue.picker.selected = index;
397            } else {
398                lost_selection = restore.selected.is_some();
399            }
400            if let Some(index) = restore.top_item {
401                glue.picker.scroll_top = index;
402            }
403        }
404        if lost > 0 || lost_selection {
405            glue.picker.warning = Some(format!(
406                "refresh: {lost} workset decision(s) no longer matched{}",
407                if lost_selection {
408                    "; selected hit changed"
409                } else {
410                    ""
411                }
412            ));
413        }
414    }
415
416    pub(crate) fn resume_search_after_review(&mut self, stamp: SearchStamp) {
417        let Some(glue) = self.retained_search.as_ref().filter(|glue| {
418            glue.search
419                .as_ref()
420                .is_some_and(|context| context.stamp.session == stamp.session)
421        }) else {
422            self.message = "the original Search investigation is no longer retained".into();
423            return;
424        };
425        let Some(context) = glue.search.as_ref() else {
426            return;
427        };
428        let scope = context.scope.clone();
429        let origin = context.origin.clone();
430        if self.docs.get(origin.document).is_some() {
431            self.jump_to(origin);
432        }
433        self.open_search_in(scope, false);
434    }
435}