1use std::sync::Arc;
2
3use crate::TaskContexts;
4use editor::Editor;
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter,
8 Focusable, InteractiveElement, ParentElement, Render, SharedString, Styled, Subscription, Task,
9 WeakEntity, Window, rems,
10};
11use itertools::Itertools;
12use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
13use project::{TaskSourceKind, task_store::TaskStore};
14use task::{DebugScenario, ResolvedTask, RevealTarget, TaskContext, TaskTemplate};
15use ui::{
16 ActiveTheme, Clickable, FluentBuilder as _, IconButtonShape, IconWithIndicator, Indicator,
17 IntoElement, KeyBinding, ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip, div,
18 prelude::*,
19};
20
21use util::{ResultExt, truncate_and_trailoff};
22use workspace::{ModalView, Workspace};
23pub use zed_actions::{Rerun, Spawn};
24
25/// A modal used to spawn new tasks.
26pub struct TasksModalDelegate {
27 task_store: Entity<TaskStore>,
28 candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
29 task_overrides: Option<TaskOverrides>,
30 last_used_candidate_index: Option<usize>,
31 divider_index: Option<usize>,
32 matches: Vec<StringMatch>,
33 selected_index: usize,
34 workspace: WeakEntity<Workspace>,
35 prompt: String,
36 task_contexts: Arc<TaskContexts>,
37 placeholder_text: Arc<str>,
38}
39
40/// Task template amendments to do before resolving the context.
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct TaskOverrides {
43 /// See [`RevealTarget`].
44 pub reveal_target: Option<RevealTarget>,
45}
46
47impl TasksModalDelegate {
48 fn new(
49 task_store: Entity<TaskStore>,
50 task_contexts: Arc<TaskContexts>,
51 task_overrides: Option<TaskOverrides>,
52 workspace: WeakEntity<Workspace>,
53 ) -> Self {
54 let placeholder_text = if let Some(TaskOverrides {
55 reveal_target: Some(RevealTarget::Center),
56 }) = &task_overrides
57 {
58 Arc::from("Find a task, or run a command in the central pane")
59 } else {
60 Arc::from("Find a task, or run a command")
61 };
62 Self {
63 task_store,
64 workspace,
65 candidates: None,
66 matches: Vec::new(),
67 last_used_candidate_index: None,
68 divider_index: None,
69 selected_index: 0,
70 prompt: String::default(),
71 task_contexts,
72 task_overrides,
73 placeholder_text,
74 }
75 }
76
77 fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
78 if self.prompt.trim().is_empty() {
79 return None;
80 }
81
82 let default_context = TaskContext::default();
83 let active_context = self
84 .task_contexts
85 .active_context()
86 .unwrap_or(&default_context);
87 let source_kind = TaskSourceKind::UserInput;
88 let id_base = source_kind.to_id_base();
89 let mut new_oneshot = TaskTemplate {
90 label: self.prompt.clone(),
91 command: self.prompt.clone(),
92 ..TaskTemplate::default()
93 };
94 if let Some(TaskOverrides {
95 reveal_target: Some(reveal_target),
96 }) = &self.task_overrides
97 {
98 new_oneshot.reveal_target = *reveal_target;
99 }
100 Some((
101 source_kind,
102 new_oneshot.resolve_task(&id_base, active_context)?,
103 ))
104 }
105
106 fn delete_previously_used(&mut self, ix: usize, cx: &mut App) {
107 let Some(candidates) = self.candidates.as_mut() else {
108 return;
109 };
110 let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
111 return;
112 };
113 // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
114 // it doesn't make sense to requery the inventory for new candidates, as that's potentially costly and more often than not it should just return back
115 // the original list without a removed entry.
116 candidates.remove(ix);
117 if let Some(inventory) = self.task_store.read(cx).task_inventory().cloned() {
118 inventory.update(cx, |inventory, _| {
119 inventory.delete_previously_used(&task.id);
120 })
121 };
122 }
123}
124
125pub struct TasksModal {
126 pub picker: Entity<Picker<TasksModalDelegate>>,
127 _subscription: [Subscription; 2],
128}
129
130impl TasksModal {
131 pub fn new(
132 task_store: Entity<TaskStore>,
133 task_contexts: Arc<TaskContexts>,
134 task_overrides: Option<TaskOverrides>,
135 is_modal: bool,
136 workspace: WeakEntity<Workspace>,
137 window: &mut Window,
138 cx: &mut Context<Self>,
139 ) -> Self {
140 let picker = cx.new(|cx| {
141 Picker::uniform_list(
142 TasksModalDelegate::new(task_store, task_contexts, task_overrides, workspace),
143 window,
144 cx,
145 )
146 .modal(is_modal)
147 });
148 let _subscription = [
149 cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
150 cx.emit(DismissEvent);
151 }),
152 cx.subscribe(&picker, |_, _, event: &ShowAttachModal, cx| {
153 cx.emit(ShowAttachModal {
154 debug_config: event.debug_config.clone(),
155 });
156 }),
157 ];
158 Self {
159 picker,
160 _subscription,
161 }
162 }
163
164 pub fn tasks_loaded(
165 &mut self,
166 task_contexts: Arc<TaskContexts>,
167 lsp_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
168 used_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
169 current_resolved_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
170 add_current_language_tasks: bool,
171 window: &mut Window,
172 cx: &mut Context<Self>,
173 ) {
174 let last_used_candidate_index = if used_tasks.is_empty() {
175 None
176 } else {
177 Some(used_tasks.len() - 1)
178 };
179 let mut new_candidates = used_tasks;
180 new_candidates.extend(lsp_tasks);
181 let hide_vscode = current_resolved_tasks.iter().any(|(kind, _)| match kind {
182 TaskSourceKind::Worktree {
183 id: _,
184 directory_in_worktree: dir,
185 id_base: _,
186 } => dir.ends_with(".zed"),
187 _ => false,
188 });
189 // todo(debugger): We're always adding lsp tasks here even if prefer_lsp is false
190 // We should move the filter to new_candidates instead of on current
191 // and add a test for this
192 new_candidates.extend(current_resolved_tasks.into_iter().filter(|(task_kind, _)| {
193 match task_kind {
194 TaskSourceKind::Worktree {
195 directory_in_worktree: dir,
196 ..
197 } => !(hide_vscode && dir.ends_with(".vscode")),
198 TaskSourceKind::Language { .. } => add_current_language_tasks,
199 _ => true,
200 }
201 }));
202 self.picker.update(cx, |picker, cx| {
203 picker.delegate.task_contexts = task_contexts;
204 picker.delegate.last_used_candidate_index = last_used_candidate_index;
205 picker.delegate.candidates = Some(new_candidates);
206 picker.refresh(window, cx);
207 cx.notify();
208 })
209 }
210}
211
212impl Render for TasksModal {
213 fn render(
214 &mut self,
215 _window: &mut Window,
216 _: &mut Context<Self>,
217 ) -> impl gpui::prelude::IntoElement {
218 v_flex()
219 .key_context("TasksModal")
220 .w(rems(34.))
221 .child(self.picker.clone())
222 }
223}
224
225pub struct ShowAttachModal {
226 pub debug_config: DebugScenario,
227}
228
229impl EventEmitter<DismissEvent> for TasksModal {}
230impl EventEmitter<ShowAttachModal> for TasksModal {}
231impl EventEmitter<ShowAttachModal> for Picker<TasksModalDelegate> {}
232
233impl Focusable for TasksModal {
234 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
235 self.picker.read(cx).focus_handle(cx)
236 }
237}
238
239impl ModalView for TasksModal {}
240
241const MAX_TAGS_LINE_LEN: usize = 30;
242
243impl PickerDelegate for TasksModalDelegate {
244 type ListItem = ListItem;
245
246 fn match_count(&self) -> usize {
247 self.matches.len()
248 }
249
250 fn selected_index(&self) -> usize {
251 self.selected_index
252 }
253
254 fn set_selected_index(
255 &mut self,
256 ix: usize,
257 _window: &mut Window,
258 _cx: &mut Context<picker::Picker<Self>>,
259 ) {
260 self.selected_index = ix;
261 }
262
263 fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc<str> {
264 self.placeholder_text.clone()
265 }
266
267 fn update_matches(
268 &mut self,
269 query: String,
270 window: &mut Window,
271 cx: &mut Context<picker::Picker<Self>>,
272 ) -> Task<()> {
273 let candidates = match &self.candidates {
274 Some(candidates) => Task::ready(string_match_candidates(candidates)),
275 None => {
276 if let Some(task_inventory) = self.task_store.read(cx).task_inventory().cloned() {
277 let task_list = task_inventory.update(cx, |this, cx| {
278 this.used_and_current_resolved_tasks(self.task_contexts.clone(), cx)
279 });
280 let workspace = self.workspace.clone();
281 let lsp_task_sources = self.task_contexts.lsp_task_sources.clone();
282 let task_position = self.task_contexts.latest_selection;
283 cx.spawn(async move |picker, cx| {
284 let (used, current) = task_list.await;
285 let Ok((lsp_tasks, prefer_lsp)) = workspace.update(cx, |workspace, cx| {
286 let lsp_tasks = editor::lsp_tasks(
287 workspace.project().clone(),
288 &lsp_task_sources,
289 task_position,
290 cx,
291 );
292 let prefer_lsp = workspace
293 .active_item(cx)
294 .and_then(|item| item.downcast::<Editor>())
295 .map(|editor| {
296 editor
297 .read(cx)
298 .buffer()
299 .read(cx)
300 .language_settings(cx)
301 .tasks
302 .prefer_lsp
303 })
304 .unwrap_or(false);
305 (lsp_tasks, prefer_lsp)
306 }) else {
307 return Vec::new();
308 };
309
310 let lsp_tasks = lsp_tasks.await;
311 picker
312 .update(cx, |picker, _| {
313 picker.delegate.last_used_candidate_index = if used.is_empty() {
314 None
315 } else {
316 Some(used.len() - 1)
317 };
318
319 let mut new_candidates = used;
320 let add_current_language_tasks =
321 !prefer_lsp || lsp_tasks.is_empty();
322 new_candidates.extend(lsp_tasks.into_iter().flat_map(
323 |(kind, tasks_with_locations)| {
324 tasks_with_locations
325 .into_iter()
326 .sorted_by_key(|(location, task)| {
327 (location.is_none(), task.resolved_label.clone())
328 })
329 .map(move |(_, task)| (kind.clone(), task))
330 },
331 ));
332 // todo(debugger): We're always adding lsp tasks here even if prefer_lsp is false
333 // We should move the filter to new_candidates instead of on current
334 // and add a test for this
335 new_candidates.extend(current.into_iter().filter(
336 |(task_kind, _)| {
337 add_current_language_tasks
338 || !matches!(task_kind, TaskSourceKind::Language { .. })
339 },
340 ));
341 let match_candidates = string_match_candidates(&new_candidates);
342 let _ = picker.delegate.candidates.insert(new_candidates);
343 match_candidates
344 })
345 .ok()
346 .unwrap_or_default()
347 })
348 } else {
349 Task::ready(Vec::new())
350 }
351 }
352 };
353
354 cx.spawn_in(window, async move |picker, cx| {
355 let candidates = candidates.await;
356 let matches = fuzzy::match_strings(
357 &candidates,
358 &query,
359 true,
360 true,
361 1000,
362 &Default::default(),
363 cx.background_executor().clone(),
364 )
365 .await;
366 picker
367 .update(cx, |picker, _| {
368 let delegate = &mut picker.delegate;
369 delegate.matches = matches;
370 if let Some(index) = delegate.last_used_candidate_index {
371 delegate.matches.sort_by_key(|m| m.candidate_id > index);
372 }
373
374 delegate.prompt = query;
375 delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
376 let index = delegate
377 .matches
378 .partition_point(|matching_task| matching_task.candidate_id <= index);
379 Some(index).and_then(|index| (index != 0).then(|| index - 1))
380 });
381
382 if delegate.matches.is_empty() {
383 delegate.selected_index = 0;
384 } else {
385 delegate.selected_index =
386 delegate.selected_index.min(delegate.matches.len() - 1);
387 }
388 })
389 .log_err();
390 })
391 }
392
393 fn confirm(
394 &mut self,
395 omit_history_entry: bool,
396 window: &mut Window,
397 cx: &mut Context<picker::Picker<Self>>,
398 ) {
399 let current_match_index = self.selected_index();
400 let task = self
401 .matches
402 .get(current_match_index)
403 .and_then(|current_match| {
404 let ix = current_match.candidate_id;
405 self.candidates
406 .as_ref()
407 .map(|candidates| candidates[ix].clone())
408 });
409 let Some((task_source_kind, mut task)) = task else {
410 return;
411 };
412 if let Some(TaskOverrides {
413 reveal_target: Some(reveal_target),
414 }) = &self.task_overrides
415 {
416 task.resolved.reveal_target = *reveal_target;
417 }
418
419 self.workspace
420 .update(cx, |workspace, cx| {
421 workspace.schedule_resolved_task(
422 task_source_kind,
423 task,
424 omit_history_entry,
425 window,
426 cx,
427 );
428 })
429 .ok();
430
431 cx.emit(DismissEvent);
432 }
433
434 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
435 cx.emit(DismissEvent);
436 }
437
438 fn render_match(
439 &self,
440 ix: usize,
441 selected: bool,
442 window: &mut Window,
443 cx: &mut Context<picker::Picker<Self>>,
444 ) -> Option<Self::ListItem> {
445 let candidates = self.candidates.as_ref()?;
446 let hit = &self.matches[ix];
447 let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
448 let template = resolved_task.original_task();
449 let display_label = resolved_task.display_label();
450
451 let mut tooltip_label_text = if display_label != &template.label {
452 resolved_task.resolved_label.clone()
453 } else {
454 String::new()
455 };
456
457 if resolved_task.resolved.command_label != resolved_task.resolved_label {
458 if !tooltip_label_text.trim().is_empty() {
459 tooltip_label_text.push('\n');
460 }
461 tooltip_label_text.push_str(&resolved_task.resolved.command_label);
462 }
463
464 if template.tags.len() > 0 {
465 tooltip_label_text.push('\n');
466 tooltip_label_text.push_str(
467 template
468 .tags
469 .iter()
470 .map(|tag| format!("\n#{}", tag))
471 .collect::<Vec<_>>()
472 .join("")
473 .as_str(),
474 );
475 }
476 let tooltip_label = if tooltip_label_text.trim().is_empty() {
477 None
478 } else {
479 Some(Tooltip::simple(tooltip_label_text, cx))
480 };
481
482 let highlighted_location = HighlightedMatch {
483 text: hit.string.clone(),
484 highlight_positions: hit.positions.clone(),
485 char_count: hit.string.chars().count(),
486 color: Color::Default,
487 };
488 let icon = match source_kind {
489 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
490 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
491 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
492 TaskSourceKind::Lsp {
493 language_name: name,
494 ..
495 }
496 | TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
497 .get_icon_for_type(&name.to_lowercase(), cx)
498 .map(Icon::from_path),
499 }
500 .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
501 let indicator = if matches!(source_kind, TaskSourceKind::Lsp { .. }) {
502 Some(Indicator::icon(
503 Icon::new(IconName::BoltOutlined).size(IconSize::Small),
504 ))
505 } else {
506 None
507 };
508 let icon = icon.map(|icon| {
509 IconWithIndicator::new(icon, indicator)
510 .indicator_border_color(Some(cx.theme().colors().border_transparent))
511 });
512 let history_run_icon = if Some(ix) <= self.divider_index {
513 Some(
514 Icon::new(IconName::HistoryRerun)
515 .color(Color::Muted)
516 .size(IconSize::Small)
517 .into_any_element(),
518 )
519 } else {
520 Some(
521 v_flex()
522 .flex_none()
523 .size(IconSize::Small.rems())
524 .into_any_element(),
525 )
526 };
527
528 Some(
529 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
530 .inset(true)
531 .start_slot::<IconWithIndicator>(icon)
532 .end_slot::<AnyElement>(
533 h_flex()
534 .gap_1()
535 .child(Label::new(truncate_and_trailoff(
536 &template
537 .tags
538 .iter()
539 .map(|tag| format!("#{}", tag))
540 .collect::<Vec<_>>()
541 .join(" "),
542 MAX_TAGS_LINE_LEN,
543 )))
544 .flex_none()
545 .child(history_run_icon.unwrap())
546 .into_any_element(),
547 )
548 .spacing(ListItemSpacing::Sparse)
549 .when_some(tooltip_label, |list_item, item_label| {
550 list_item.tooltip(move |_, _| item_label.clone())
551 })
552 .map(|item| {
553 let item = if matches!(source_kind, TaskSourceKind::UserInput)
554 || Some(ix) <= self.divider_index
555 {
556 let task_index = hit.candidate_id;
557 let delete_button = div().child(
558 IconButton::new("delete", IconName::Close)
559 .shape(IconButtonShape::Square)
560 .icon_color(Color::Muted)
561 .size(ButtonSize::None)
562 .icon_size(IconSize::XSmall)
563 .on_click(cx.listener(move |picker, _event, window, cx| {
564 cx.stop_propagation();
565 window.prevent_default();
566
567 picker.delegate.delete_previously_used(task_index, cx);
568 picker.delegate.last_used_candidate_index = picker
569 .delegate
570 .last_used_candidate_index
571 .unwrap_or(0)
572 .checked_sub(1);
573 picker.refresh(window, cx);
574 }))
575 .tooltip(|_, cx| {
576 Tooltip::simple("Delete Previously Scheduled Task", cx)
577 }),
578 );
579 item.end_hover_slot(delete_button)
580 } else {
581 item
582 };
583 item
584 })
585 .toggle_state(selected)
586 .child(highlighted_location.render(window, cx)),
587 )
588 }
589
590 fn confirm_completion(
591 &mut self,
592 _: String,
593 _window: &mut Window,
594 _: &mut Context<Picker<Self>>,
595 ) -> Option<String> {
596 let task_index = self.matches.get(self.selected_index())?.candidate_id;
597 let tasks = self.candidates.as_ref()?;
598 let (_, task) = tasks.get(task_index)?;
599 Some(task.resolved.command_label.clone())
600 }
601
602 fn confirm_input(
603 &mut self,
604 omit_history_entry: bool,
605 window: &mut Window,
606 cx: &mut Context<Picker<Self>>,
607 ) {
608 let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
609 return;
610 };
611
612 if let Some(TaskOverrides {
613 reveal_target: Some(reveal_target),
614 }) = self.task_overrides
615 {
616 task.resolved.reveal_target = reveal_target;
617 }
618 self.workspace
619 .update(cx, |workspace, cx| {
620 workspace.schedule_resolved_task(
621 task_source_kind,
622 task,
623 omit_history_entry,
624 window,
625 cx,
626 )
627 })
628 .ok();
629 cx.emit(DismissEvent);
630 }
631
632 fn separators_after_indices(&self) -> Vec<usize> {
633 if let Some(i) = self.divider_index {
634 vec![i]
635 } else {
636 Vec::new()
637 }
638 }
639
640 fn render_footer(
641 &self,
642 window: &mut Window,
643 cx: &mut Context<Picker<Self>>,
644 ) -> Option<gpui::AnyElement> {
645 let is_recent_selected = self.divider_index >= Some(self.selected_index);
646 let current_modifiers = window.modifiers();
647 let left_button = if self
648 .task_store
649 .read(cx)
650 .task_inventory()?
651 .read(cx)
652 .last_scheduled_task(None)
653 .is_some()
654 {
655 Some(("Rerun Last Task", Rerun::default().boxed_clone()))
656 } else {
657 None
658 };
659 Some(
660 h_flex()
661 .w_full()
662 .p_1p5()
663 .justify_between()
664 .border_t_1()
665 .border_color(cx.theme().colors().border_variant)
666 .child(
667 left_button
668 .map(|(label, action)| {
669 let keybind = KeyBinding::for_action(&*action, window, cx);
670
671 Button::new("edit-current-task", label)
672 .when_some(keybind, |this, keybind| this.key_binding(keybind))
673 .on_click(move |_, window, cx| {
674 window.dispatch_action(action.boxed_clone(), cx);
675 })
676 .into_any_element()
677 })
678 .unwrap_or_else(|| h_flex().into_any_element()),
679 )
680 .map(|this| {
681 if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
682 {
683 let action = picker::ConfirmInput {
684 secondary: current_modifiers.secondary(),
685 }
686 .boxed_clone();
687 this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
688 let spawn_oneshot_label = if current_modifiers.secondary() {
689 "Spawn Oneshot Without History"
690 } else {
691 "Spawn Oneshot"
692 };
693
694 Button::new("spawn-onehshot", spawn_oneshot_label)
695 .key_binding(keybind)
696 .on_click(move |_, window, cx| {
697 window.dispatch_action(action.boxed_clone(), cx)
698 })
699 }))
700 } else if current_modifiers.secondary() {
701 this.children(
702 KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
703 |keybind| {
704 let label = if is_recent_selected {
705 "Rerun Without History"
706 } else {
707 "Spawn Without History"
708 };
709 Button::new("spawn", label).key_binding(keybind).on_click(
710 move |_, window, cx| {
711 window.dispatch_action(
712 menu::SecondaryConfirm.boxed_clone(),
713 cx,
714 )
715 },
716 )
717 },
718 ),
719 )
720 } else {
721 this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
722 |keybind| {
723 let run_entry_label =
724 if is_recent_selected { "Rerun" } else { "Spawn" };
725
726 Button::new("spawn", run_entry_label)
727 .key_binding(keybind)
728 .on_click(|_, window, cx| {
729 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
730 })
731 },
732 ))
733 }
734 })
735 .into_any_element(),
736 )
737 }
738}
739
740fn string_match_candidates<'a>(
741 candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
742) -> Vec<StringMatchCandidate> {
743 candidates
744 .into_iter()
745 .enumerate()
746 .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
747 .collect()
748}
749
750#[cfg(test)]
751mod tests {
752 use std::{path::PathBuf, sync::Arc};
753
754 use editor::{Editor, SelectionEffects};
755 use gpui::{TestAppContext, VisualTestContext};
756 use language::{Language, LanguageConfig, LanguageMatcher, Point};
757 use project::{ContextProviderWithTasks, FakeFs, Project};
758 use serde_json::json;
759 use task::TaskTemplates;
760 use util::path;
761 use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
762
763 use crate::{modal::Spawn, tests::init_test};
764
765 use super::*;
766
767 #[gpui::test]
768 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
769 init_test(cx);
770 let fs = FakeFs::new(cx.executor());
771 fs.insert_tree(
772 path!("/dir"),
773 json!({
774 ".zed": {
775 "tasks.json": r#"[
776 {
777 "label": "example task",
778 "command": "echo",
779 "args": ["4"]
780 },
781 {
782 "label": "another one",
783 "command": "echo",
784 "args": ["55"]
785 },
786 ]"#,
787 },
788 "a.ts": "a"
789 }),
790 )
791 .await;
792
793 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
794 let (workspace, cx) =
795 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
796
797 let tasks_picker = open_spawn_tasks(&workspace, cx);
798 assert_eq!(
799 query(&tasks_picker, cx),
800 "",
801 "Initial query should be empty"
802 );
803 assert_eq!(
804 task_names(&tasks_picker, cx),
805 vec!["another one", "example task"],
806 "With no global tasks and no open item, a single worktree should be used and its tasks listed"
807 );
808 drop(tasks_picker);
809
810 let _ = workspace
811 .update_in(cx, |workspace, window, cx| {
812 workspace.open_abs_path(
813 PathBuf::from(path!("/dir/a.ts")),
814 OpenOptions {
815 visible: Some(OpenVisible::All),
816 ..Default::default()
817 },
818 window,
819 cx,
820 )
821 })
822 .await
823 .unwrap();
824 let tasks_picker = open_spawn_tasks(&workspace, cx);
825 assert_eq!(
826 task_names(&tasks_picker, cx),
827 vec!["another one", "example task"],
828 "Initial tasks should be listed in alphabetical order"
829 );
830
831 let query_str = "tas";
832 cx.simulate_input(query_str);
833 assert_eq!(query(&tasks_picker, cx), query_str);
834 assert_eq!(
835 task_names(&tasks_picker, cx),
836 vec!["example task"],
837 "Only one task should match the query {query_str}"
838 );
839
840 cx.dispatch_action(picker::ConfirmCompletion);
841 assert_eq!(
842 query(&tasks_picker, cx),
843 "echo 4",
844 "Query should be set to the selected task's command"
845 );
846 assert_eq!(
847 task_names(&tasks_picker, cx),
848 Vec::<String>::new(),
849 "No task should be listed"
850 );
851 cx.dispatch_action(picker::ConfirmInput { secondary: false });
852
853 let tasks_picker = open_spawn_tasks(&workspace, cx);
854 assert_eq!(
855 query(&tasks_picker, cx),
856 "",
857 "Query should be reset after confirming"
858 );
859 assert_eq!(
860 task_names(&tasks_picker, cx),
861 vec!["echo 4", "another one", "example task"],
862 "New oneshot task should be listed first"
863 );
864
865 let query_str = "echo 4";
866 cx.simulate_input(query_str);
867 assert_eq!(query(&tasks_picker, cx), query_str);
868 assert_eq!(
869 task_names(&tasks_picker, cx),
870 vec!["echo 4"],
871 "New oneshot should match custom command query"
872 );
873
874 cx.dispatch_action(picker::ConfirmInput { secondary: false });
875 let tasks_picker = open_spawn_tasks(&workspace, cx);
876 assert_eq!(
877 query(&tasks_picker, cx),
878 "",
879 "Query should be reset after confirming"
880 );
881 assert_eq!(
882 task_names(&tasks_picker, cx),
883 vec![query_str, "another one", "example task"],
884 "Last recently used one show task should be listed first"
885 );
886
887 cx.dispatch_action(picker::ConfirmCompletion);
888 assert_eq!(
889 query(&tasks_picker, cx),
890 query_str,
891 "Query should be set to the custom task's name"
892 );
893 assert_eq!(
894 task_names(&tasks_picker, cx),
895 vec![query_str],
896 "Only custom task should be listed"
897 );
898
899 let query_str = "0";
900 cx.simulate_input(query_str);
901 assert_eq!(query(&tasks_picker, cx), "echo 40");
902 assert_eq!(
903 task_names(&tasks_picker, cx),
904 Vec::<String>::new(),
905 "New oneshot should not match any command query"
906 );
907
908 cx.dispatch_action(picker::ConfirmInput { secondary: true });
909 let tasks_picker = open_spawn_tasks(&workspace, cx);
910 assert_eq!(
911 query(&tasks_picker, cx),
912 "",
913 "Query should be reset after confirming"
914 );
915 assert_eq!(
916 task_names(&tasks_picker, cx),
917 vec!["echo 4", "another one", "example task"],
918 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
919 );
920
921 cx.dispatch_action(Spawn::ByName {
922 task_name: "example task".to_string(),
923 reveal_target: None,
924 });
925 let tasks_picker = workspace.update(cx, |workspace, cx| {
926 workspace
927 .active_modal::<TasksModal>(cx)
928 .unwrap()
929 .read(cx)
930 .picker
931 .clone()
932 });
933 assert_eq!(
934 task_names(&tasks_picker, cx),
935 vec!["echo 4", "another one", "example task"],
936 );
937 }
938
939 #[gpui::test]
940 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
941 init_test(cx);
942 let fs = FakeFs::new(cx.executor());
943 fs.insert_tree(
944 path!("/dir"),
945 json!({
946 ".zed": {
947 "tasks.json": r#"[
948 {
949 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
950 "command": "echo",
951 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
952 },
953 {
954 "label": "opened now: $ZED_WORKTREE_ROOT",
955 "command": "echo",
956 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
957 }
958 ]"#,
959 },
960 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
961 "file_with.odd_extension": "b",
962 }),
963 )
964 .await;
965
966 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
967 let (workspace, cx) =
968 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
969
970 let tasks_picker = open_spawn_tasks(&workspace, cx);
971 assert_eq!(
972 task_names(&tasks_picker, cx),
973 vec![concat!("opened now: ", path!("/dir")).to_string()],
974 "When no file is open for a single worktree, should autodetect all worktree-related tasks"
975 );
976 tasks_picker.update(cx, |_, cx| {
977 cx.emit(DismissEvent);
978 });
979 drop(tasks_picker);
980 cx.executor().run_until_parked();
981
982 let _ = workspace
983 .update_in(cx, |workspace, window, cx| {
984 workspace.open_abs_path(
985 PathBuf::from(path!("/dir/file_with.odd_extension")),
986 OpenOptions {
987 visible: Some(OpenVisible::All),
988 ..Default::default()
989 },
990 window,
991 cx,
992 )
993 })
994 .await
995 .unwrap();
996 cx.executor().run_until_parked();
997 let tasks_picker = open_spawn_tasks(&workspace, cx);
998 assert_eq!(
999 task_names(&tasks_picker, cx),
1000 vec![
1001 concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
1002 concat!("opened now: ", path!("/dir")).to_string(),
1003 ],
1004 "Second opened buffer should fill the context, labels should be trimmed if long enough"
1005 );
1006 tasks_picker.update(cx, |_, cx| {
1007 cx.emit(DismissEvent);
1008 });
1009 drop(tasks_picker);
1010 cx.executor().run_until_parked();
1011
1012 let second_item = workspace
1013 .update_in(cx, |workspace, window, cx| {
1014 workspace.open_abs_path(
1015 PathBuf::from(path!("/dir/file_without_extension")),
1016 OpenOptions {
1017 visible: Some(OpenVisible::All),
1018 ..Default::default()
1019 },
1020 window,
1021 cx,
1022 )
1023 })
1024 .await
1025 .unwrap();
1026
1027 let editor = cx
1028 .update(|_window, cx| second_item.act_as::<Editor>(cx))
1029 .unwrap();
1030 editor.update_in(cx, |editor, window, cx| {
1031 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1032 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1033 })
1034 });
1035 cx.executor().run_until_parked();
1036 let tasks_picker = open_spawn_tasks(&workspace, cx);
1037 assert_eq!(
1038 task_names(&tasks_picker, cx),
1039 vec![
1040 concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1041 concat!("opened now: ", path!("/dir")).to_string(),
1042 ],
1043 "Opened buffer should fill the context, labels should be trimmed if long enough"
1044 );
1045 tasks_picker.update(cx, |_, cx| {
1046 cx.emit(DismissEvent);
1047 });
1048 drop(tasks_picker);
1049 cx.executor().run_until_parked();
1050 }
1051
1052 #[gpui::test]
1053 async fn test_language_task_filtering(cx: &mut TestAppContext) {
1054 init_test(cx);
1055 let fs = FakeFs::new(cx.executor());
1056 fs.insert_tree(
1057 path!("/dir"),
1058 json!({
1059 "a1.ts": "// a1",
1060 "a2.ts": "// a2",
1061 "b.rs": "// b",
1062 }),
1063 )
1064 .await;
1065
1066 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1067 project.read_with(cx, |project, _| {
1068 let language_registry = project.languages();
1069 language_registry.add(Arc::new(
1070 Language::new(
1071 LanguageConfig {
1072 name: "TypeScript".into(),
1073 matcher: LanguageMatcher {
1074 path_suffixes: vec!["ts".to_string()],
1075 ..LanguageMatcher::default()
1076 },
1077 ..LanguageConfig::default()
1078 },
1079 None,
1080 )
1081 .with_context_provider(Some(Arc::new(
1082 ContextProviderWithTasks::new(TaskTemplates(vec![
1083 TaskTemplate {
1084 label: "Task without variables".to_string(),
1085 command: "npm run clean".to_string(),
1086 ..TaskTemplate::default()
1087 },
1088 TaskTemplate {
1089 label: "TypeScript task from file $ZED_FILE".to_string(),
1090 command: "npm run build".to_string(),
1091 ..TaskTemplate::default()
1092 },
1093 TaskTemplate {
1094 label: "Another task from file $ZED_FILE".to_string(),
1095 command: "npm run lint".to_string(),
1096 ..TaskTemplate::default()
1097 },
1098 ])),
1099 ))),
1100 ));
1101 language_registry.add(Arc::new(
1102 Language::new(
1103 LanguageConfig {
1104 name: "Rust".into(),
1105 matcher: LanguageMatcher {
1106 path_suffixes: vec!["rs".to_string()],
1107 ..LanguageMatcher::default()
1108 },
1109 ..LanguageConfig::default()
1110 },
1111 None,
1112 )
1113 .with_context_provider(Some(Arc::new(
1114 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1115 label: "Rust task".to_string(),
1116 command: "cargo check".into(),
1117 ..TaskTemplate::default()
1118 }])),
1119 ))),
1120 ));
1121 });
1122 let (workspace, cx) =
1123 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1124
1125 let _ts_file_1 = workspace
1126 .update_in(cx, |workspace, window, cx| {
1127 workspace.open_abs_path(
1128 PathBuf::from(path!("/dir/a1.ts")),
1129 OpenOptions {
1130 visible: Some(OpenVisible::All),
1131 ..Default::default()
1132 },
1133 window,
1134 cx,
1135 )
1136 })
1137 .await
1138 .unwrap();
1139 let tasks_picker = open_spawn_tasks(&workspace, cx);
1140 assert_eq!(
1141 task_names(&tasks_picker, cx),
1142 vec![
1143 concat!("Another task from file ", path!("/dir/a1.ts")),
1144 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1145 "Task without variables",
1146 ],
1147 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1148 );
1149
1150 emulate_task_schedule(
1151 tasks_picker,
1152 &project,
1153 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1154 cx,
1155 );
1156
1157 let tasks_picker = open_spawn_tasks(&workspace, cx);
1158 assert_eq!(
1159 task_names(&tasks_picker, cx),
1160 vec![
1161 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1162 concat!("Another task from file ", path!("/dir/a1.ts")),
1163 "Task without variables",
1164 ],
1165 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1166 Tasks with the same labels and context are deduplicated."
1167 );
1168 tasks_picker.update(cx, |_, cx| {
1169 cx.emit(DismissEvent);
1170 });
1171 drop(tasks_picker);
1172 cx.executor().run_until_parked();
1173
1174 let _ts_file_2 = workspace
1175 .update_in(cx, |workspace, window, cx| {
1176 workspace.open_abs_path(
1177 PathBuf::from(path!("/dir/a2.ts")),
1178 OpenOptions {
1179 visible: Some(OpenVisible::All),
1180 ..Default::default()
1181 },
1182 window,
1183 cx,
1184 )
1185 })
1186 .await
1187 .unwrap();
1188 let tasks_picker = open_spawn_tasks(&workspace, cx);
1189 assert_eq!(
1190 task_names(&tasks_picker, cx),
1191 vec![
1192 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1193 concat!("Another task from file ", path!("/dir/a2.ts")),
1194 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1195 "Task without variables",
1196 ],
1197 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1198 );
1199 tasks_picker.update(cx, |_, cx| {
1200 cx.emit(DismissEvent);
1201 });
1202 drop(tasks_picker);
1203 cx.executor().run_until_parked();
1204
1205 let _rs_file = workspace
1206 .update_in(cx, |workspace, window, cx| {
1207 workspace.open_abs_path(
1208 PathBuf::from(path!("/dir/b.rs")),
1209 OpenOptions {
1210 visible: Some(OpenVisible::All),
1211 ..Default::default()
1212 },
1213 window,
1214 cx,
1215 )
1216 })
1217 .await
1218 .unwrap();
1219 let tasks_picker = open_spawn_tasks(&workspace, cx);
1220 assert_eq!(
1221 task_names(&tasks_picker, cx),
1222 vec!["Rust task"],
1223 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1224 );
1225
1226 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1227 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1228 let _ts_file_2 = workspace
1229 .update_in(cx, |workspace, window, cx| {
1230 workspace.open_abs_path(
1231 PathBuf::from(path!("/dir/a2.ts")),
1232 OpenOptions {
1233 visible: Some(OpenVisible::All),
1234 ..Default::default()
1235 },
1236 window,
1237 cx,
1238 )
1239 })
1240 .await
1241 .unwrap();
1242 let tasks_picker = open_spawn_tasks(&workspace, cx);
1243 assert_eq!(
1244 task_names(&tasks_picker, cx),
1245 vec![
1246 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1247 concat!("Another task from file ", path!("/dir/a2.ts")),
1248 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1249 "Task without variables",
1250 ],
1251 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1252 same TS spawn history should be restored"
1253 );
1254 }
1255
1256 fn emulate_task_schedule(
1257 tasks_picker: Entity<Picker<TasksModalDelegate>>,
1258 project: &Entity<Project>,
1259 scheduled_task_label: &str,
1260 cx: &mut VisualTestContext,
1261 ) {
1262 let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1263 tasks_picker
1264 .delegate
1265 .candidates
1266 .iter()
1267 .flatten()
1268 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1269 .cloned()
1270 .unwrap()
1271 });
1272 project.update(cx, |project, cx| {
1273 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1274 task_inventory.update(cx, |inventory, _| {
1275 let (kind, task) = scheduled_task;
1276 inventory.task_scheduled(kind, task);
1277 });
1278 }
1279 });
1280 tasks_picker.update(cx, |_, cx| {
1281 cx.emit(DismissEvent);
1282 });
1283 drop(tasks_picker);
1284 cx.executor().run_until_parked()
1285 }
1286
1287 fn open_spawn_tasks(
1288 workspace: &Entity<Workspace>,
1289 cx: &mut VisualTestContext,
1290 ) -> Entity<Picker<TasksModalDelegate>> {
1291 cx.dispatch_action(Spawn::modal());
1292 workspace.update(cx, |workspace, cx| {
1293 workspace
1294 .active_modal::<TasksModal>(cx)
1295 .expect("no task modal after `Spawn` action was dispatched")
1296 .read(cx)
1297 .picker
1298 .clone()
1299 })
1300 }
1301
1302 fn query(
1303 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1304 cx: &mut VisualTestContext,
1305 ) -> String {
1306 spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1307 }
1308
1309 fn task_names(
1310 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1311 cx: &mut VisualTestContext,
1312 ) -> Vec<String> {
1313 spawn_tasks.read_with(cx, |spawn_tasks, _| {
1314 spawn_tasks
1315 .delegate
1316 .matches
1317 .iter()
1318 .map(|hit| hit.string.clone())
1319 .collect::<Vec<_>>()
1320 })
1321 }
1322}