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::RemoteAddress {
164 self.open_remote_address();
165 return;
166 }
167 let items = match kind {
168 Kind::Buffers => self
169 .mru
170 .iter()
171 .map(|&i| {
172 let name = match self.doc(i).buf.path.as_ref() {
173 Some(path) => path.to_string_lossy().into_owned(),
174 None => "[scratch]".into(),
175 };
176 Item {
177 text: name,
178 payload: Payload::Buffer(i),
179 }
180 })
181 .collect(),
182 Kind::Files
185 | Kind::Grep
186 | Kind::Replace
187 | Kind::RemoteHosts
188 | Kind::RemoteAddress
189 | Kind::CodeActions
190 | Kind::Containers => vec![],
191 Kind::Diagnostics | Kind::Locations => {
192 unreachable!("location lists use PickerGlue::diagnostics")
193 }
194 };
195 self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
196 if kind == Kind::Files {
197 self.launch_files_request();
198 }
199 }
200
201 fn launch_files_request(&mut self) {
206 let Some(picker) = self.picker.as_ref().map(|glue| glue.id) else {
207 return;
208 };
209 let request = match self.worker_ids.allocate() {
210 Ok(request) => request,
211 Err(error) => {
212 self.message = error.message;
213 return;
214 }
215 };
216 let ticket = Ticket {
217 request,
218 key: PickerKey {
219 picker,
220 cwd: self.cwd.clone(),
221 },
222 };
223 if let Some(glue) = self.picker.as_mut() {
224 glue.active = Some(ticket.clone());
225 glue.picker.streaming = true;
226 }
227 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
228 serde_json::json!({
229 "service":"picker","source":"files","id":picker.0.get(),
230 "request":request.get(),"cwd":self.cwd.to_string_lossy(),
231 })
232 });
233 match self
234 .tape
235 .request("picker-files", &serde_json::json!({"ticket":ticket}))
236 {
237 Ok(false) => return,
238 Ok(true) => {}
239 Err(error) => {
240 self.handle_picker_event(PickerEvent {
241 ticket,
242 msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
243 strop_core::worker::FailureKind::Protocol,
244 error.to_string(),
245 )),
246 });
247 return;
248 }
249 }
250 let (tx, rx) = channel();
251 let worker = spawn_files(self.cwd.clone(), tx);
252 if let Some(glue) = self.picker.as_mut() {
253 glue.worker = Some(PickerWorker::Files(worker));
254 }
255 self.attach_picker_stream(ticket, rx);
256 }
257
258 fn attach_picker_stream(&mut self, ticket: Ticket<PickerKey>, rx: Receiver<PickerMsg>) {
261 let Some(app_tx) = self.app_tx.clone() else {
262 if let Some(glue) = self.picker.as_mut() {
263 glue.rx = Some((ticket, rx));
264 }
265 return;
266 };
267 if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), app_tx) {
268 self.handle_picker_event(PickerEvent {
270 ticket,
271 msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
272 strop_core::worker::FailureKind::ThreadStart,
273 format!("picker bridge: {error}"),
274 )),
275 });
276 }
277 }
278
279 pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
282 if let Some(glue) = &mut self.picker {
283 if let Some((ticket, rx)) = glue.rx.take() {
284 let _ = drain::forward_picker_stream(rx, ticket, tx.clone());
285 }
286 }
287 }
288
289 pub fn close_picker(&mut self) {
293 let Some(mut glue) = self.picker.take() else {
294 return;
295 };
296 glue.revoke(CancelReason::OwnerClosed);
297 self.revoke_remote_chooser(glue.id);
298 self.revoke_picker_previews(glue.id);
299 if glue.rank_alive {
300 self.picker_ranking.retiring.insert(glue.id);
301 }
302 if let Some(worker) = glue.rank_worker.take() {
303 if let Err(error) = worker.retire(glue.picker) {
304 self.message = format!("picker cleanup failed: {error}");
305 }
306 }
307 }
308
309 pub fn picker_open(&self) -> bool {
310 self.picker.is_some()
311 }
312
313 fn revoke_picker_previews(&mut self, picker: PickerId) {
318 let mut cancelled = Vec::new();
319 let mut forgotten = Vec::new();
320 self.preview_loads.retain(|path, load| match load {
321 Load::Running(ticket) if ticket.key.picker == picker => {
322 cancelled.push(ticket.request);
323 false
324 }
325 Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
326 forgotten.push(path.clone());
327 false
328 }
329 _ => true,
330 });
331 for request in cancelled {
332 if let Some(handle) = self.worker_handles.remove(&request) {
333 handle.cancel(CancelReason::OwnerClosed);
334 }
335 }
336 for path in forgotten {
337 self.previews.remove(&path);
338 self.analysis
339 .forget(super::analysis::AnalysisTarget::Preview(path));
340 }
341 }
342
343 pub(crate) fn feed_picker(&mut self, key: Key) {
344 let Some(glue) = &mut self.picker else {
345 return;
346 };
347 if key != Key::Enter {
348 glue.accept_when_ranked = false;
349 }
350 let replace = glue.picker.kind == Kind::Replace;
351 match key {
352 Key::Esc => {
353 if glue.picker.input_normal() {
354 self.close_picker();
355 } else {
356 glue.picker.enter_normal();
357 }
358 }
359 Key::Enter => self.accept_current_picker(),
360 Key::Tab | Key::Backtab if replace => glue.picker.toggle_field(),
361 Key::CtrlO => self.open_collection_from_picker(),
363 Key::CtrlD if replace => glue.picker.toggle_file_excluded(),
364 Key::CtrlD => {}
365 Key::CtrlX => {}
366 Key::Backspace => {
367 if glue.picker.input_normal() {
368 glue.picker.normal_key('h');
369 } else if replace && glue.picker.field == strop_picker::Field::Replace {
370 glue.picker.pop_replace_char();
371 } else {
372 glue.picker.pop_char();
373 self.picker_input_changed();
374 }
375 }
376 Key::CtrlL => self.needs_repaint = true,
377 Key::CtrlR | Key::CtrlW => {}
378 Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
379 Key::Up => glue.picker.move_by(-1),
380 Key::Down => glue.picker.move_by(1),
381 Key::Tab => glue.picker.move_by(1),
382 Key::Backtab => glue.picker.move_by(-1),
383 Key::Left => glue.picker.caret_left(),
384 Key::Right => glue.picker.caret_right(),
385 Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
386 Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
387 Key::Char(c) => {
388 if glue.picker.input_normal() {
389 if glue.picker.normal_key(c) {
390 self.picker_input_changed();
391 }
392 } else if replace && glue.picker.field == strop_picker::Field::Replace {
393 glue.picker.push_replace_char(c);
394 } else {
395 glue.picker.push_char(c);
396 self.picker_input_changed();
397 }
398 }
399 }
400 }
401
402 pub(crate) fn paste_picker(&mut self, text: &str) {
408 let Some(glue) = &mut self.picker else {
409 return;
410 };
411 if text.contains(['\r', '\n']) {
412 self.message = "picker input cannot contain a newline".into();
413 return;
414 }
415 if glue.picker.paste(text) {
416 self.picker_input_changed();
417 }
418 }
419
420 pub(crate) fn accept_current_picker(&mut self) {
421 if self
422 .picker
423 .as_ref()
424 .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
425 {
426 self.accept_remote_address();
427 return;
428 }
429 let Some(glue) = self.picker.as_mut() else {
430 return;
431 };
432 let replacing = glue.picker.kind == Kind::Replace;
433 if (replacing || glue.picker.current().is_none())
434 && (glue.rank_pending.is_some() || glue.picker.streaming)
435 {
436 glue.accept_when_ranked = true;
437 return;
438 }
439 glue.accept_when_ranked = false;
440 if replacing {
441 self.apply_replace();
442 return;
443 }
444 let payload = glue.picker.current().map(|item| item.payload.clone());
445 if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
451 {
452 let draft = {
453 let text = glue.picker.input.text.trim();
454 let aimed_at_row = glue
458 .picker
459 .current()
460 .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
461 (!aimed_at_row).then(|| text.to_string())
462 };
463 self.close_picker();
464 self.open_remote_address();
465 if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
466 if let Some(glue) = self.picker.as_mut() {
467 glue.picker.paste(&draft);
468 }
469 }
470 return;
471 }
472 let Some(payload) = payload else {
473 self.message = "no matching entries".into();
474 return;
475 };
476 let context = glue.lsp_context;
477 self.close_picker();
478 self.accept_picker(payload, context);
479 }
480
481 pub(crate) fn finish_pending_picker_accept(&mut self) {
482 if self.picker.as_ref().is_some_and(|glue| {
483 glue.accept_when_ranked
484 && glue.rank_pending.is_none()
485 && (glue.picker.kind != Kind::Replace || !glue.picker.streaming)
486 }) {
487 self.accept_current_picker();
488 }
489 }
490
491 fn picker_input_changed(&mut self) {
495 let (query, picker) = {
496 let Some(glue) = &mut self.picker else {
497 return;
498 };
499 if glue.picker.kind == Kind::RemoteAddress {
500 glue.picker.error = None;
501 return;
502 }
503 if !matches!(glue.picker.kind, Kind::Grep | Kind::Replace) {
504 self.request_picker_ranking();
505 return;
506 }
507 let query = glue.picker.input.text.clone();
508 let picker = glue.id;
509 glue.revoke(CancelReason::Superseded);
510 glue.picker.error = None;
511 glue.picker.clear_items();
512 glue.ranked_query = None;
513 glue.rank_pending = None;
514 (query, picker)
515 };
516 let request = match self.worker_ids.allocate() {
517 Ok(request) => request,
518 Err(error) => {
519 if let Some(glue) = self.picker.as_mut() {
522 glue.picker.streaming = false;
523 }
524 self.message = error.message;
525 return;
526 }
527 };
528 let ticket = Ticket {
529 request,
530 key: PickerKey {
531 picker,
532 cwd: self.cwd.clone(),
533 },
534 };
535 if let Some(glue) = self.picker.as_mut() {
537 glue.active = Some(ticket.clone());
538 glue.picker.streaming = true;
539 }
540 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
541 serde_json::json!({
542 "service":"picker","source":"grep","id":picker.0.get(),
543 "request":request.get(),"query":query,"streaming":true,
544 })
545 });
546 match self.tape.request(
547 "picker-grep",
548 &serde_json::json!({"ticket":ticket,"query":query}),
549 ) {
550 Ok(false) => return,
551 Ok(true) => {}
552 Err(error) => {
553 self.handle_picker_event(PickerEvent {
554 ticket,
555 msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
556 strop_core::worker::FailureKind::Protocol,
557 error.to_string(),
558 )),
559 });
560 return;
561 }
562 }
563 let (tx, rx) = channel();
564 let worker = GrepWorker::spawn(&query, &self.cwd, tx);
565 if let Some(glue) = self.picker.as_mut() {
566 glue.worker = Some(PickerWorker::Grep(worker));
567 }
568 self.attach_picker_stream(ticket, rx);
569 }
570}
571
572pub struct PreviewEntry {
573 pub rope: ropey::Rope,
574}
575
576pub enum PreviewSource {
577 Buffer(strop_core::id::DocumentId),
578 Cached(PathBuf),
579 Loading,
580 Failed(String),
581 Cancelled(CancelReason),
582}
583
584pub type Previews = HashMap<PathBuf, PreviewEntry>;