strop_engine/editor/picker/
search.rs1use 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 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}