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