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