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