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;
23#[cfg(test)]
24mod tests;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30#[serde(transparent)]
31pub struct PickerId(pub WorkerId);
32
33#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct PickerKey {
37 pub picker: PickerId,
38 #[serde(with = "strop_core::path_serde")]
39 pub cwd: PathBuf,
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct PickerEvent {
47 pub ticket: Ticket<PickerKey>,
48 pub msg: PickerMsg,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54pub struct PreviewKey {
55 pub picker: PickerId,
56 #[serde(with = "strop_core::path_serde")]
57 pub path: PathBuf,
58}
59
60pub type PreviewResult = strop_core::worker::Completion<PreviewKey, preview::PreparedPreview>;
62
63pub(crate) enum PickerWorker {
65 Files(CancelHandle),
66 Grep(GrepWorker),
67}
68
69impl PickerWorker {
70 pub(crate) fn cancel(self, reason: CancelReason) {
71 match self {
72 PickerWorker::Files(handle) => handle.cancel(reason),
73 PickerWorker::Grep(worker) => worker.cancel(reason),
74 }
75 }
76}
77
78pub struct PickerGlue {
79 pub picker: Picker,
80 pub id: PickerId,
81 pub(crate) active: Option<Ticket<PickerKey>>,
84 pub(crate) rx: Option<(Ticket<PickerKey>, Receiver<PickerMsg>)>,
87 pub(crate) worker: Option<PickerWorker>,
88 pub(crate) lsp_context: Option<strop_lsp::ReplyContext>,
89 pub(crate) rank_worker: Option<strop_picker::RankingWorker<ranking::Key>>,
90 pub rank_pending: Option<Ticket<ranking::Key>>,
91 pub(crate) rank_dirty: bool,
93 pub(crate) ranked_query: Option<String>,
94 pub(crate) rank_alive: bool,
95 pub(crate) accept_when_ranked: bool,
96 pub suggestions: Option<SuggestionList>,
99 pub query_highlights: Vec<strop_picker::query::HighlightSpan>,
100 pub query_summary: String,
101 pub(crate) query: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
102 file_scope: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
103 pub(crate) indent_target: Option<strop_core::id::DocumentId>,
104}
105
106pub struct SuggestionList {
109 pub items: Vec<strop_picker::query::suggest::Suggestion>,
110 pub selected: usize,
111}
112
113impl PickerGlue {
114 pub fn diagnostics(picker: Picker) -> Self {
119 Self {
120 picker,
121 id: PickerId(WorkerId::new(0)), active: None,
123 rx: None,
124 worker: None,
125 lsp_context: None,
126 rank_worker: None,
127 rank_pending: None,
128 rank_dirty: false,
129 ranked_query: None,
130 rank_alive: false,
131 accept_when_ranked: false,
132 suggestions: None,
133 query_highlights: Vec::new(),
134 query_summary: String::new(),
135 file_scope: None,
136 query: None,
137 indent_target: None,
138 }
139 }
140
141 fn revoke(&mut self, reason: CancelReason) {
145 self.rx = None;
146 if self.active.take().is_some() {
147 if let Some(worker) = self.worker.take() {
148 worker.cancel(reason);
149 }
150 }
151 }
152}
153
154impl Editor {
155 pub(crate) fn set_picker(&mut self, mut glue: PickerGlue) {
159 self.cancel_pending();
160 self.close_picker();
161 let id = match self.worker_ids.allocate() {
162 Ok(id) => id,
163 Err(error) => {
164 self.message = error.message;
165 return;
166 }
167 };
168 glue.id = PickerId(id);
169 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
170 serde_json::json!({
171 "service":"picker","id":id.get(),
172 "kind":glue.picker.kind.title().trim(),"streaming":glue.picker.streaming,
173 })
174 });
175 self.picker = Some(glue);
176 if self
177 .picker
178 .as_ref()
179 .is_some_and(|glue| glue.picker.kind != Kind::RemoteAddress)
180 {
181 self.start_picker_ranking();
182 }
183 }
184
185 pub fn open_picker(&mut self, kind: Kind) {
186 if kind == Kind::RemoteHosts {
187 self.open_remote_picker();
188 return;
189 }
190 if kind == Kind::Jumps {
191 self.open_jumps_picker();
192 return;
193 }
194 if kind == Kind::RemoteAddress {
195 self.open_remote_address();
196 return;
197 }
198 if kind == Kind::TabSize {
199 self.open_tab_size_picker();
200 return;
201 }
202 let items = match kind {
203 Kind::Buffers => self
204 .mru
205 .iter()
206 .map(|&i| {
207 let name = match self.doc(i).buf.path.as_ref() {
208 Some(path) => path.to_string_lossy().into_owned(),
209 None => "[scratch]".into(),
210 };
211 Item {
212 badge: None,
213 text: name,
214 payload: Payload::Buffer(i),
215 }
216 })
217 .collect(),
218 Kind::Files
221 | Kind::Grep
222 | Kind::Replace
223 | Kind::RemoteHosts
224 | Kind::RemoteAddress
225 | Kind::CodeActions
226 | Kind::Containers => vec![],
227 Kind::Jumps => unreachable!("the jumplist builds its own items"),
228 Kind::SearchOptions => unreachable!("search options build their own items"),
229 Kind::TabSize => unreachable!("the tab-size selector builds its own items"),
230 Kind::Symbols => vec![],
231 Kind::Diagnostics | Kind::Locations => {
232 unreachable!("location lists use PickerGlue::diagnostics")
233 }
234 };
235 self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
236 if kind == Kind::Files {
237 self.picker_input_changed();
238 }
239 }
240
241 pub(crate) fn open_search_options(&mut self) {
244 let item = |setting: strop_picker::SearchSetting, on: bool| strop_picker::Item {
245 badge: None,
246 text: format!(
247 "{}: {}",
248 match setting {
249 strop_picker::SearchSetting::Hidden => "hidden (dotfiles)",
250 strop_picker::SearchSetting::RespectIgnore => "ignored entries",
251 },
252 match (setting, on) {
253 (strop_picker::SearchSetting::Hidden, true)
254 | (strop_picker::SearchSetting::RespectIgnore, false) => "include",
255 _ => "exclude",
256 },
257 ),
258 payload: strop_picker::Payload::SearchOption(setting),
259 };
260 let items = vec![
261 item(
262 strop_picker::SearchSetting::Hidden,
263 self.config.search_show_hidden,
264 ),
265 item(
266 strop_picker::SearchSetting::RespectIgnore,
267 self.config.search_respect_ignore,
268 ),
269 ];
270 self.set_picker(PickerGlue::diagnostics(Picker::new(
271 Kind::SearchOptions,
272 items,
273 false,
274 )));
275 }
276
277 pub(crate) fn open_jumps_picker(&mut self) {
280 let mut items = Vec::new();
281 for entry in self.jumplist_past.iter().rev() {
282 items.extend(jump_row(self, entry, " "));
283 }
284 items.extend(jump_row(self, &self.jump_record(), "> "));
285 for entry in self.jumplist_future.iter().rev() {
286 items.extend(jump_row(self, entry, " "));
287 }
288 self.set_picker(PickerGlue::diagnostics(Picker::new(
289 Kind::Jumps,
290 items,
291 false,
292 )));
293 }
294
295 fn attach_picker_stream(&mut self, ticket: Ticket<PickerKey>, rx: Receiver<PickerMsg>) {
298 let Some(app_tx) = self.app_tx.clone() else {
299 if let Some(glue) = self.picker.as_mut() {
300 glue.rx = Some((ticket, rx));
301 }
302 return;
303 };
304 if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), app_tx) {
305 self.handle_picker_event(PickerEvent {
307 ticket,
308 msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
309 strop_core::worker::FailureKind::ThreadStart,
310 format!("picker bridge: {error}"),
311 )),
312 });
313 }
314 }
315
316 pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
319 if let Some(glue) = &mut self.picker {
320 if let Some((ticket, rx)) = glue.rx.take() {
321 let _ = drain::forward_picker_stream(rx, ticket, tx.clone());
322 }
323 }
324 }
325
326 pub fn close_picker(&mut self) {
330 let Some(mut glue) = self.picker.take() else {
331 return;
332 };
333 glue.revoke(CancelReason::OwnerClosed);
334 self.revoke_remote_chooser(glue.id);
335 self.revoke_picker_previews(glue.id);
336 if glue.rank_alive {
337 self.picker_ranking.retiring.insert(glue.id);
338 }
339 if let Some(worker) = glue.rank_worker.take() {
340 if let Err(error) = worker.retire(glue.picker) {
341 self.message = format!("picker cleanup failed: {error}");
342 }
343 }
344 }
345
346 pub fn picker_open(&self) -> bool {
347 self.picker.is_some()
348 }
349
350 fn revoke_picker_previews(&mut self, picker: PickerId) {
355 let mut cancelled = Vec::new();
356 let mut forgotten = Vec::new();
357 self.preview_loads.retain(|path, load| match load {
358 Load::Running(ticket) if ticket.key.picker == picker => {
359 cancelled.push(ticket.request);
360 false
361 }
362 Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
363 forgotten.push(path.clone());
364 false
365 }
366 _ => true,
367 });
368 for request in cancelled {
369 if let Some(handle) = self.worker_handles.remove(&request) {
370 handle.cancel(CancelReason::OwnerClosed);
371 }
372 }
373 for path in forgotten {
374 self.previews.remove(&path);
375 self.analysis
376 .forget(super::analysis::AnalysisTarget::Preview(path));
377 }
378 }
379
380 pub(crate) fn feed_picker(&mut self, key: Key) {
381 let Some(glue) = &mut self.picker else {
382 return;
383 };
384 if key != Key::Enter {
385 glue.accept_when_ranked = false;
386 }
387 let replace = glue.picker.kind == Kind::Replace;
388 if glue.suggestions.is_some() {
390 match key {
391 Key::Up => {
392 let list = glue.suggestions.as_mut().unwrap();
393 list.selected = list.selected.saturating_sub(1);
394 return;
395 }
396 Key::Down | Key::Tab => {
397 let list = glue.suggestions.as_mut().unwrap();
398 list.selected = (list.selected + 1).min(list.items.len().saturating_sub(1));
399 return;
400 }
401 Key::Enter => {
402 self.accept_suggestion();
403 return;
404 }
405 Key::Esc => {
406 glue.suggestions = None;
407 return;
408 }
409 _ => {
410 glue.suggestions = None;
411 }
412 }
413 }
414 match key {
415 Key::CtrlSpace => {
416 self.open_suggestions();
417 }
418 Key::Esc => {
419 if glue.picker.input_normal() {
420 self.close_picker();
421 } else {
422 glue.picker.enter_normal();
423 }
424 }
425 Key::Enter => self.accept_current_picker(),
426 Key::Tab | Key::Backtab if replace => glue.picker.toggle_field(),
427 Key::CtrlO => self.open_collection_from_picker(),
429 Key::CtrlD if replace => glue.picker.toggle_file_excluded(),
430 Key::CtrlD => {}
431 Key::CtrlX => {}
432 Key::Backspace => {
433 if glue.picker.input_normal() {
434 glue.picker.normal_key('h');
435 } else if replace && glue.picker.field == strop_picker::Field::Replace {
436 glue.picker.pop_replace_char();
437 } else {
438 glue.picker.pop_char();
439 self.picker_input_changed();
440 }
441 }
442 Key::CtrlL => self.needs_repaint = true,
443 Key::CtrlR | Key::CtrlW => {}
444 Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
445 Key::Up => glue.picker.move_by(-1),
446 Key::Down => glue.picker.move_by(1),
447 Key::Tab => glue.picker.move_by(1),
448 Key::Backtab => glue.picker.move_by(-1),
449 Key::Left => glue.picker.caret_left(),
450 Key::Right => glue.picker.caret_right(),
451 Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
452 Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
453 Key::Char(c) => {
454 if glue.picker.input_normal() {
455 if glue.picker.normal_key(c) {
456 self.picker_input_changed();
457 }
458 } else if replace && glue.picker.field == strop_picker::Field::Replace {
459 glue.picker.push_replace_char(c);
460 } else {
461 glue.picker.push_char(c);
462 self.picker_input_changed();
463 }
464 }
465 }
466 }
467
468 pub(crate) fn paste_picker(&mut self, text: &str) {
474 let Some(glue) = &mut self.picker else {
475 return;
476 };
477 if text.contains(['\r', '\n']) {
478 self.message = "picker input cannot contain a newline".into();
479 return;
480 }
481 if glue.picker.paste(text) {
482 self.picker_input_changed();
483 }
484 }
485
486 pub(crate) fn accept_current_picker(&mut self) {
487 if self
488 .picker
489 .as_ref()
490 .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
491 {
492 self.accept_remote_address();
493 return;
494 }
495 let Some(glue) = self.picker.as_mut() else {
496 return;
497 };
498 if matches!(glue.picker.kind, Kind::Files | Kind::Grep | Kind::Replace) {
499 if let Some(error) = &glue.picker.error {
500 self.message = error.clone();
501 return;
502 }
503 }
504 let replacing = glue.picker.kind == Kind::Replace;
505 if glue.rank_pending.is_some()
506 || ((replacing || glue.picker.current().is_none()) && glue.picker.streaming)
507 {
508 glue.accept_when_ranked = true;
509 return;
510 }
511 glue.accept_when_ranked = false;
512 if replacing {
513 self.apply_replace();
514 return;
515 }
516 let payload = glue.picker.current().map(|item| item.payload.clone());
517 if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
523 {
524 let draft = {
525 let text = glue.picker.input.text.trim();
526 let aimed_at_row = glue
530 .picker
531 .current()
532 .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
533 (!aimed_at_row).then(|| text.to_string())
534 };
535 self.close_picker();
536 self.open_remote_address();
537 if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
538 if let Some(glue) = self.picker.as_mut() {
539 glue.picker.paste(&draft);
540 }
541 }
542 return;
543 }
544 if glue.picker.kind == Kind::TabSize {
548 let draft = glue.picker.input.text.trim().to_string();
549 let Some(Payload::IndentChoice(choice)) = payload else {
550 self.message = "no matching entries".into();
551 return;
552 };
553 let Some(document) = glue.indent_target else {
554 self.message = "indentation selector lost its source — reopen :tab-size".into();
555 return;
556 };
557 self.close_picker();
558 self.accept_indent_choice(document, choice, &draft);
559 return;
560 }
561 let Some(payload) = payload else {
562 self.message = "no matching entries".into();
563 return;
564 };
565 let context = glue.lsp_context;
566 self.close_picker();
567 self.accept_picker(payload, context);
568 }
569
570 pub(crate) fn finish_pending_picker_accept(&mut self) {
571 if self.picker.as_ref().is_some_and(|glue| {
572 glue.accept_when_ranked
573 && glue.rank_pending.is_none()
574 && (glue.picker.kind != Kind::Replace || !glue.picker.streaming)
575 }) {
576 self.accept_current_picker();
577 }
578 }
579}
580
581pub struct PreviewEntry {
582 pub rope: ropey::Rope,
583}
584
585pub enum PreviewSource {
586 Buffer(strop_core::id::DocumentId),
587 Cached(PathBuf),
588 Loading,
589 Failed(String),
590 Cancelled(CancelReason),
591}
592
593pub type Previews = HashMap<PathBuf, PreviewEntry>;
594
595fn jump_row(editor: &Editor, record: &super::jumps::JumpRecord, marker: &str) -> Option<Item> {
599 let doc = editor.docs.get(record.document)?;
600 let name = doc
601 .buf
602 .path
603 .as_ref()
604 .map(|path| path.to_string_lossy().into_owned())
605 .unwrap_or_else(|| "[scratch]".into());
606 let line = doc.buf.line_of(record.offset.min(doc.buf.len_bytes()));
607 let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
608 Some(Item {
609 badge: None,
610 text: format!("{marker}{name}:{} {text}", line + 1),
611 payload: Payload::Jump {
612 document: record.document,
613 offset: record.offset,
614 },
615 })
616}