1use 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::{Item, Kind, Payload, Picker, PickerMsg};
12
13use super::{Editor, Key};
14
15mod accept;
16mod drain;
17mod preview;
18mod query;
19#[cfg(test)]
20mod query_tests;
21pub(crate) mod ranking;
22mod replace;
23pub use replace::checked_hit_range;
24pub use replace::ReplacementHit;
25pub(crate) mod search;
26pub use search::SearchScope;
27#[cfg(test)]
28mod search_tests;
29#[cfg(test)]
30mod tests;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[serde(transparent)]
37pub struct PickerId(pub WorkerId);
38
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct PickerKey {
43 pub picker: PickerId,
44 #[serde(with = "strop_core::path_serde")]
45 pub cwd: PathBuf,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PickerEvent {
53 pub ticket: Ticket<PickerKey>,
54 pub msg: PickerMsg,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct PreviewKey {
61 pub picker: PickerId,
62 pub path: strop_workspace::ResourceLocation,
63}
64
65pub type PreviewResult = strop_core::worker::Completion<PreviewKey, preview::PreparedPreview>;
67
68pub struct PickerGlue {
69 pub picker: Picker,
70 pub id: PickerId,
71 pub(crate) active: Option<Ticket<PickerKey>>,
74 pub(crate) rx: Option<(Ticket<PickerKey>, Receiver<PickerMsg>)>,
77 pub(crate) worker: Option<CancelHandle>,
78 pub(crate) lsp_context: Option<strop_lsp::ReplyContext>,
79 pub(crate) rank_worker: Option<strop_picker::RankingWorker<ranking::Key>>,
80 pub rank_pending: Option<Ticket<ranking::Key>>,
81 pub(crate) rank_dirty: bool,
83 pub(crate) ranked_query: Option<String>,
84 pub(crate) rank_alive: bool,
85 pub(crate) accept_when_ranked: bool,
86 pub suggestions: Option<SuggestionList>,
89 pub query_highlights: Vec<strop_picker::query::HighlightSpan>,
90 pub query_summary: String,
91 pub(crate) query: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
92 file_scope: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
93 pub(crate) indent_target: Option<strop_core::id::DocumentId>,
94 pub(crate) search: Option<search::SearchContext>,
95 preview_witness: Option<preview::WitnessCheck>,
96}
97
98pub struct SuggestionList {
101 pub items: Vec<strop_picker::query::suggest::Suggestion>,
102 pub selected: usize,
103}
104
105impl PickerGlue {
106 pub fn diagnostics(picker: Picker) -> Self {
111 Self {
112 picker,
113 id: PickerId(WorkerId::new(0)), active: None,
115 rx: None,
116 worker: None,
117 lsp_context: None,
118 rank_worker: None,
119 rank_pending: None,
120 rank_dirty: false,
121 ranked_query: None,
122 rank_alive: false,
123 accept_when_ranked: false,
124 suggestions: None,
125 query_highlights: Vec::new(),
126 query_summary: String::new(),
127 file_scope: None,
128 query: None,
129 indent_target: None,
130 search: None,
131 preview_witness: None,
132 }
133 }
134
135 fn revoke(&mut self, reason: CancelReason) {
139 self.rx = None;
140 if self.active.take().is_some() {
141 if let Some(worker) = self.worker.take() {
142 worker.cancel(reason);
143 }
144 }
145 }
146}
147
148impl Editor {
149 pub(crate) fn set_picker(&mut self, mut glue: PickerGlue) {
153 self.cancel_pending();
154 self.close_picker();
155 let id = match self.worker_ids.allocate() {
156 Ok(id) => id,
157 Err(error) => {
158 self.message = error.message;
159 return;
160 }
161 };
162 glue.preview_witness = None;
163 glue.id = PickerId(id);
164 if glue.picker.kind == Kind::Search && glue.search.is_none() {
165 match self.new_search_context(SearchScope {
166 root: strop_workspace::ResourceLocation::local(self.cwd.clone()),
167 }) {
168 Ok(context) => glue.search = Some(context),
169 Err(error) => {
170 self.message = error;
171 return;
172 }
173 }
174 }
175 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
176 serde_json::json!({
177 "service":"picker","id":id.get(),
178 "kind":glue.picker.kind.title().trim(),"streaming":glue.picker.streaming,
179 })
180 });
181 self.picker = Some(glue);
182 if self
183 .picker
184 .as_ref()
185 .is_some_and(|glue| glue.picker.kind != Kind::RemoteAddress)
186 {
187 self.start_picker_ranking();
188 }
189 }
190
191 pub fn open_picker(&mut self, kind: Kind) {
192 if kind == Kind::FilesystemActions {
193 self.open_filesystem_actions();
194 return;
195 }
196 if kind == Kind::Search {
197 self.open_search(false);
198 return;
199 }
200 if kind == Kind::RemoteHosts {
201 self.open_remote_picker();
202 return;
203 }
204 if kind == Kind::Jumps {
205 self.open_jumps_picker();
206 return;
207 }
208 if kind == Kind::RemoteAddress {
209 self.open_remote_address();
210 return;
211 }
212 if kind == Kind::TabSize {
213 self.open_tab_size_picker();
214 return;
215 }
216 let items = match kind {
217 Kind::Buffers => self
218 .mru
219 .iter()
220 .map(|&i| {
221 let name = match self.doc(i).buf.path.as_ref() {
222 Some(path) => path.to_string_lossy().into_owned(),
223 None => "[scratch]".into(),
224 };
225 Item {
226 badge: None,
227 text: name,
228 payload: Payload::Buffer(i),
229 }
230 })
231 .collect(),
232 Kind::Files
235 | Kind::Search
236 | Kind::RemoteHosts
237 | Kind::RemoteAddress
238 | Kind::CodeActions
239 | Kind::Containers => vec![],
240 Kind::Jumps => unreachable!("the jumplist builds its own items"),
241 Kind::SearchOptions => unreachable!("search options build their own items"),
242 Kind::TabSize => unreachable!("the tab-size selector builds its own items"),
243 Kind::FilesystemActions => {
244 unreachable!("filesystem actions build their own captured selector")
245 }
246 Kind::Symbols => vec![],
247 Kind::Diagnostics | Kind::Locations => {
248 unreachable!("location lists use PickerGlue::diagnostics")
249 }
250 };
251 self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
252 if kind == Kind::Files {
253 self.picker_input_changed();
254 }
255 }
256
257 pub(crate) fn open_search_options(&mut self) {
260 let item = |setting: strop_picker::SearchSetting, on: bool| strop_picker::Item {
261 badge: None,
262 text: format!(
263 "{}: {}",
264 match setting {
265 strop_picker::SearchSetting::Hidden => "hidden (dotfiles)",
266 strop_picker::SearchSetting::RespectIgnore => "ignored entries",
267 },
268 match (setting, on) {
269 (strop_picker::SearchSetting::Hidden, true)
270 | (strop_picker::SearchSetting::RespectIgnore, false) => "include",
271 _ => "exclude",
272 },
273 ),
274 payload: strop_picker::Payload::SearchOption(setting),
275 };
276 let items = vec![
277 item(
278 strop_picker::SearchSetting::Hidden,
279 self.config.search_show_hidden,
280 ),
281 item(
282 strop_picker::SearchSetting::RespectIgnore,
283 self.config.search_respect_ignore,
284 ),
285 ];
286 self.set_picker(PickerGlue::diagnostics(Picker::new(
287 Kind::SearchOptions,
288 items,
289 false,
290 )));
291 }
292
293 pub(crate) fn open_jumps_picker(&mut self) {
296 let mut items = Vec::new();
297 for entry in self.jumplist_past.iter().rev() {
298 items.extend(jump_row(self, entry, " "));
299 }
300 items.extend(jump_row(self, &self.jump_record(), "> "));
301 for entry in self.jumplist_future.iter().rev() {
302 items.extend(jump_row(self, entry, " "));
303 }
304 self.set_picker(PickerGlue::diagnostics(Picker::new(
305 Kind::Jumps,
306 items,
307 false,
308 )));
309 }
310
311 fn picker_source_sink(&mut self, ticket: Ticket<PickerKey>) -> strop_picker::SourceSink {
314 if let Some(app_tx) = self.app_tx.clone() {
315 return strop_picker::SourceSink::new(move |msg| {
316 app_tx
317 .send(super::events::AppEvent::Picker(PickerEvent {
318 ticket: ticket.clone(),
319 msg,
320 }))
321 .is_ok()
322 });
323 }
324 let (tx, rx) = channel();
325 if let Some(glue) = self.picker.as_mut() {
326 glue.rx = Some((ticket, rx));
327 }
328 tx.into()
329 }
330
331 pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
334 if let Some(glue) = &mut self.picker {
335 if let Some((ticket, rx)) = glue.rx.take() {
336 if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), tx.clone()) {
337 let _ = tx.send(super::events::AppEvent::Picker(PickerEvent {
338 ticket,
339 msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
340 strop_core::worker::FailureKind::ThreadStart,
341 format!("picker bridge: {error}"),
342 )),
343 }));
344 }
345 }
346 }
347 }
348
349 pub fn close_picker(&mut self) {
353 let Some(mut glue) = self.picker.take() else {
354 return;
355 };
356 glue.revoke(CancelReason::OwnerClosed);
357 self.revoke_remote_chooser(glue.id);
358 self.revoke_filesystem_actions(glue.id);
359 self.revoke_picker_previews(glue.id);
360 if glue.rank_alive {
361 self.picker_ranking.retiring.insert(glue.id);
362 }
363 if glue.picker.kind == Kind::Search {
364 drop(glue.rank_worker.take());
365 self.retain_search(glue);
366 } else if let Some(worker) = glue.rank_worker.take() {
367 if let Err(error) = worker.retire(glue.picker) {
368 self.message = format!("picker cleanup failed: {error}");
369 }
370 }
371 }
372
373 pub fn picker_open(&self) -> bool {
374 self.picker.is_some()
375 }
376
377 fn revoke_picker_previews(&mut self, picker: PickerId) {
382 let mut cancelled = Vec::new();
383 let mut forgotten = Vec::new();
384 self.preview_loads.retain(|path, load| match load {
385 Load::Running(ticket) if ticket.key.picker == picker => {
386 cancelled.push(ticket.request);
387 false
388 }
389 Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
390 forgotten.push(path.clone());
391 false
392 }
393 _ => true,
394 });
395 for request in cancelled {
396 if let Some(handle) = self.worker_handles.remove(&request) {
397 handle.cancel(CancelReason::OwnerClosed);
398 }
399 }
400 for path in forgotten {
401 self.previews.remove(&path);
402 self.analysis
403 .forget(super::analysis::AnalysisTarget::Preview(path));
404 }
405 }
406
407 pub(crate) fn feed_picker(&mut self, key: Key) {
408 let Some(glue) = &mut self.picker else {
409 return;
410 };
411 if key != Key::Enter {
412 glue.accept_when_ranked = false;
413 }
414 let search = glue.picker.kind == Kind::Search;
415 let replace = search && glue.picker.replacement_visible;
416 if glue.suggestions.is_some() {
418 match key {
419 Key::Up => {
420 let list = glue.suggestions.as_mut().unwrap();
421 list.selected = list.selected.saturating_sub(1);
422 return;
423 }
424 Key::Down | Key::Tab => {
425 let list = glue.suggestions.as_mut().unwrap();
426 list.selected = (list.selected + 1).min(list.items.len().saturating_sub(1));
427 return;
428 }
429 Key::Enter => {
430 self.accept_suggestion();
431 return;
432 }
433 Key::Esc => {
434 glue.suggestions = None;
435 return;
436 }
437 _ => {
438 glue.suggestions = None;
439 }
440 }
441 }
442 match key {
443 Key::CtrlSpace => {
444 self.open_suggestions();
445 }
446 Key::Esc => {
447 if glue.picker.input_normal() {
448 let origin = glue.search.as_ref().map(|context| context.origin.clone());
449 self.close_picker();
450 if let Some(origin) =
451 origin.filter(|origin| self.docs.get(origin.document).is_some())
452 {
453 self.jump_to(origin);
454 }
455 } else {
456 glue.picker.enter_normal();
457 }
458 }
459 Key::Enter => self.accept_current_picker(),
460 Key::Tab | Key::Backtab if replace => {
461 if glue.search.as_ref().is_some_and(|context| {
462 context.scope.root.filesystem != strop_workspace::Filesystem::Local
463 }) {
464 self.message =
465 "SSH Search is read-only; With and Review are unavailable".into();
466 } else {
467 glue.picker.toggle_field();
468 }
469 }
470 Key::CtrlO => self.open_collection_from_picker(),
472 Key::CtrlD if search => {
473 if glue.picker.toggle_file_excluded() {
474 self.search_intent_changed();
475 } else {
476 self.message = "no source match selected".into();
477 }
478 }
479 Key::CtrlX if search => {
480 if glue.picker.toggle_excluded() {
481 self.search_intent_changed();
482 } else {
483 self.message = "no source match selected".into();
484 }
485 }
486 Key::CtrlD | Key::CtrlX => {}
487 Key::Backspace => {
488 if glue.picker.input_normal() {
489 glue.picker.normal_key('h');
490 } else if replace && glue.picker.field == strop_picker::Field::Replace {
491 glue.picker.pop_replace_char();
492 self.search_intent_changed();
493 } else {
494 glue.picker.pop_char();
495 self.picker_input_changed();
496 }
497 }
498 Key::CtrlL => self.needs_repaint = true,
499 Key::CtrlR if search => self.toggle_search_replacement(),
500 Key::CtrlR | Key::CtrlW => {}
501 Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
502 Key::Up => glue.picker.move_by(-1),
503 Key::Down => glue.picker.move_by(1),
504 Key::Tab => glue.picker.move_by(1),
505 Key::Backtab => glue.picker.move_by(-1),
506 Key::Left => glue.picker.caret_left(),
507 Key::Right => glue.picker.caret_right(),
508 Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
509 Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
510 Key::Char(c) => {
511 if glue.picker.input_normal() {
512 if glue.picker.normal_key(c) {
513 if replace && glue.picker.field == strop_picker::Field::Replace {
514 self.search_intent_changed();
515 } else {
516 self.picker_input_changed();
517 }
518 }
519 } else if replace && glue.picker.field == strop_picker::Field::Replace {
520 glue.picker.push_replace_char(c);
521 self.search_intent_changed();
522 } else {
523 glue.picker.push_char(c);
524 self.picker_input_changed();
525 }
526 }
527 }
528 }
529
530 pub(crate) fn paste_picker(&mut self, text: &str) {
536 if text.is_empty() {
537 return;
538 }
539 let Some(glue) = &mut self.picker else {
540 return;
541 };
542 if text.contains(['\r', '\n']) {
543 self.message = "picker input cannot contain a newline".into();
544 return;
545 }
546 if glue.picker.paste(text) {
547 self.picker_input_changed();
548 } else {
549 self.search_intent_changed();
550 }
551 }
552
553 pub(crate) fn accept_current_picker(&mut self) {
554 if self
555 .picker
556 .as_ref()
557 .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
558 {
559 self.accept_remote_address();
560 return;
561 }
562 let Some(glue) = self.picker.as_mut() else {
563 return;
564 };
565 if matches!(glue.picker.kind, Kind::Files | Kind::Search) {
566 if let Some(error) = &glue.picker.error {
567 self.message = error.clone();
568 return;
569 }
570 }
571 let replacing = glue.picker.kind == Kind::Search
572 && glue.picker.replacement_visible
573 && glue.picker.field == strop_picker::Field::Replace;
574 if glue.rank_pending.is_some()
575 || ((replacing || glue.picker.current().is_none()) && glue.picker.streaming)
576 {
577 glue.accept_when_ranked = true;
578 return;
579 }
580 glue.accept_when_ranked = false;
581 if replacing {
582 self.prepare_search_review();
583 return;
584 }
585 let payload = glue.picker.current().map(|item| item.payload.clone());
586 if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
592 {
593 let draft = {
594 let text = glue.picker.input.text.trim();
595 let aimed_at_row = glue
599 .picker
600 .current()
601 .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
602 (!aimed_at_row).then(|| text.to_string())
603 };
604 self.close_picker();
605 self.open_remote_address();
606 if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
607 if let Some(glue) = self.picker.as_mut() {
608 glue.picker.paste(&draft);
609 }
610 }
611 return;
612 }
613 if glue.picker.kind == Kind::TabSize {
617 let draft = glue.picker.input.text.trim().to_string();
618 let Some(Payload::IndentChoice(choice)) = payload else {
619 self.message = "no matching entries".into();
620 return;
621 };
622 let Some(document) = glue.indent_target else {
623 self.message = "indentation selector lost its source — reopen :tab-size".into();
624 return;
625 };
626 self.close_picker();
627 self.accept_indent_choice(document, choice, &draft);
628 return;
629 }
630 if glue.picker.kind == Kind::FilesystemActions {
631 let Some(Payload::FilesystemAction(index)) = payload else {
632 self.message = "no matching filesystem action".into();
633 return;
634 };
635 let picker = glue.id;
636 self.accept_filesystem_action(picker, index);
637 return;
638 }
639 let Some(payload) = payload else {
640 self.message = "no matching entries".into();
641 return;
642 };
643 if glue.picker.kind == Kind::Search {
644 self.open_search_hit(payload);
645 return;
646 }
647 let context = glue.lsp_context;
648 self.close_picker();
649 self.accept_picker(payload, context);
650 }
651
652 pub(crate) fn finish_pending_picker_accept(&mut self) {
653 if self.picker.as_ref().is_some_and(|glue| {
654 glue.accept_when_ranked
655 && glue.rank_pending.is_none()
656 && (!(glue.picker.kind == Kind::Search
657 && glue.picker.replacement_visible
658 && glue.picker.field == strop_picker::Field::Replace)
659 || !glue.picker.streaming)
660 }) {
661 self.accept_current_picker();
662 }
663 }
664}
665
666pub struct PreviewEntry {
667 pub rope: ropey::Rope,
668}
669
670pub enum PreviewSource {
671 Buffer(strop_core::id::DocumentId),
672 Cached(strop_workspace::ResourceLocation),
673 Loading,
674 Failed(String),
675 Cancelled(CancelReason),
676}
677
678pub type Previews = HashMap<strop_workspace::ResourceLocation, PreviewEntry>;
679
680fn jump_row(editor: &Editor, record: &super::jumps::JumpRecord, marker: &str) -> Option<Item> {
684 let doc = editor.docs.get(record.document)?;
685 let name = doc
686 .buf
687 .path
688 .as_ref()
689 .map(|path| path.to_string_lossy().into_owned())
690 .unwrap_or_else(|| "[scratch]".into());
691 let line = doc.buf.line_of(record.offset.min(doc.buf.len_bytes()));
692 let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
693 Some(Item {
694 badge: None,
695 text: format!("{marker}{name}:{} {text}", line + 1),
696 payload: Payload::Jump {
697 document: record.document,
698 offset: record.offset,
699 },
700 })
701}