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