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