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: 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 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}