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