1use std::sync::Arc;
2
3use crate::{active_item_selection_properties, schedule_resolved_task};
4use fuzzy::{StringMatch, StringMatchCandidate};
5use gpui::{
6 impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, Global,
7 InteractiveElement, Model, ParentElement, Render, SharedString, Styled, Subscription, View,
8 ViewContext, VisualContext, WeakView,
9};
10use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate};
11use project::{Inventory, TaskSourceKind};
12use task::{ResolvedTask, TaskContext, TaskTemplate};
13use ui::{
14 div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton,
15 IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable,
16 Tooltip, WindowContext,
17};
18use util::ResultExt;
19use workspace::{ModalView, Workspace};
20
21use serde::Deserialize;
22
23/// Spawn a task with name or open tasks modal
24#[derive(PartialEq, Clone, Deserialize, Default)]
25pub struct Spawn {
26 #[serde(default)]
27 /// Name of the task to spawn.
28 /// If it is not set, a modal with a list of available tasks is opened instead.
29 /// Defaults to None.
30 pub task_name: Option<String>,
31}
32
33impl Spawn {
34 pub(crate) fn modal() -> Self {
35 Self { task_name: None }
36 }
37}
38/// Rerun last task
39#[derive(PartialEq, Clone, Deserialize, Default)]
40pub struct Rerun {
41 /// Controls whether the task context is reevaluated prior to execution of a task.
42 /// If it is not, environment variables such as ZED_COLUMN, ZED_FILE are gonna be the same as in the last execution of a task
43 /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
44 /// default: false
45 #[serde(default)]
46 pub reevaluate_context: bool,
47 /// Overrides `allow_concurrent_runs` property of the task being reran.
48 /// Default: null
49 #[serde(default)]
50 pub allow_concurrent_runs: Option<bool>,
51 /// Overrides `use_new_terminal` property of the task being reran.
52 /// Default: null
53 #[serde(default)]
54 pub use_new_terminal: Option<bool>,
55}
56
57impl_actions!(task, [Rerun, Spawn]);
58
59/// A modal used to spawn new tasks.
60pub(crate) struct TasksModalDelegate {
61 inventory: Model<Inventory>,
62 candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
63 last_used_candidate_index: Option<usize>,
64 matches: Vec<StringMatch>,
65 selected_index: usize,
66 workspace: WeakView<Workspace>,
67 prompt: String,
68 task_context: TaskContext,
69 placeholder_text: Arc<str>,
70}
71
72impl TasksModalDelegate {
73 fn new(
74 inventory: Model<Inventory>,
75 task_context: TaskContext,
76 workspace: WeakView<Workspace>,
77 ) -> Self {
78 Self {
79 inventory,
80 workspace,
81 candidates: None,
82 matches: Vec::new(),
83 last_used_candidate_index: None,
84 selected_index: 0,
85 prompt: String::default(),
86 task_context,
87 placeholder_text: Arc::from("Run a task..."),
88 }
89 }
90
91 fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
92 if self.prompt.trim().is_empty() {
93 return None;
94 }
95
96 let source_kind = TaskSourceKind::UserInput;
97 let id_base = source_kind.to_id_base();
98 let new_oneshot = TaskTemplate {
99 label: self.prompt.clone(),
100 command: self.prompt.clone(),
101 ..TaskTemplate::default()
102 };
103 Some((
104 source_kind,
105 new_oneshot.resolve_task(&id_base, &self.task_context)?,
106 ))
107 }
108
109 fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
110 let Some(candidates) = self.candidates.as_mut() else {
111 return;
112 };
113 let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
114 return;
115 };
116 // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
117 // 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
118 // the original list without a removed entry.
119 candidates.remove(ix);
120 self.inventory.update(cx, |inventory, _| {
121 inventory.delete_previously_used(&task.id);
122 });
123 }
124}
125
126pub(crate) struct TasksModal {
127 picker: View<Picker<TasksModalDelegate>>,
128 _subscription: Subscription,
129}
130
131impl TasksModal {
132 pub(crate) fn new(
133 inventory: Model<Inventory>,
134 task_context: TaskContext,
135 workspace: WeakView<Workspace>,
136 cx: &mut ViewContext<Self>,
137 ) -> Self {
138 let picker = cx.new_view(|cx| {
139 Picker::uniform_list(
140 TasksModalDelegate::new(inventory, task_context, workspace),
141 cx,
142 )
143 });
144 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
145 cx.emit(DismissEvent);
146 });
147 Self {
148 picker,
149 _subscription,
150 }
151 }
152}
153
154impl Render for TasksModal {
155 fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
156 v_flex()
157 .key_context("TasksModal")
158 .w(rems(34.))
159 .child(self.picker.clone())
160 }
161}
162
163impl EventEmitter<DismissEvent> for TasksModal {}
164
165impl FocusableView for TasksModal {
166 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
167 self.picker.read(cx).focus_handle(cx)
168 }
169}
170
171impl ModalView for TasksModal {}
172
173impl PickerDelegate for TasksModalDelegate {
174 type ListItem = ListItem;
175
176 fn match_count(&self) -> usize {
177 self.matches.len()
178 }
179
180 fn selected_index(&self) -> usize {
181 self.selected_index
182 }
183
184 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
185 self.selected_index = ix;
186 }
187
188 fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
189 self.placeholder_text.clone()
190 }
191
192 fn update_matches(
193 &mut self,
194 query: String,
195 cx: &mut ViewContext<picker::Picker<Self>>,
196 ) -> gpui::Task<()> {
197 cx.spawn(move |picker, mut cx| async move {
198 let Some(candidates) = picker
199 .update(&mut cx, |picker, cx| {
200 let candidates = match &mut picker.delegate.candidates {
201 Some(candidates) => candidates,
202 None => {
203 let Ok((worktree, language)) =
204 picker.delegate.workspace.update(cx, |workspace, cx| {
205 active_item_selection_properties(workspace, cx)
206 })
207 else {
208 return Vec::new();
209 };
210 let (used, current) =
211 picker.delegate.inventory.update(cx, |inventory, cx| {
212 inventory.used_and_current_resolved_tasks(
213 language,
214 worktree,
215 &picker.delegate.task_context,
216 cx,
217 )
218 });
219 picker.delegate.last_used_candidate_index = if used.is_empty() {
220 None
221 } else {
222 Some(used.len() - 1)
223 };
224
225 let mut new_candidates = used;
226 new_candidates.extend(current);
227 picker.delegate.candidates.insert(new_candidates)
228 }
229 };
230 candidates
231 .iter()
232 .enumerate()
233 .map(|(index, (_, candidate))| StringMatchCandidate {
234 id: index,
235 char_bag: candidate.resolved_label.chars().collect(),
236 string: candidate.display_label().to_owned(),
237 })
238 .collect::<Vec<_>>()
239 })
240 .ok()
241 else {
242 return;
243 };
244 let matches = fuzzy::match_strings(
245 &candidates,
246 &query,
247 true,
248 1000,
249 &Default::default(),
250 cx.background_executor().clone(),
251 )
252 .await;
253 picker
254 .update(&mut cx, |picker, _| {
255 let delegate = &mut picker.delegate;
256 delegate.matches = matches;
257 delegate.prompt = query;
258
259 if delegate.matches.is_empty() {
260 delegate.selected_index = 0;
261 } else {
262 delegate.selected_index =
263 delegate.selected_index.min(delegate.matches.len() - 1);
264 }
265 })
266 .log_err();
267 })
268 }
269
270 fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
271 let current_match_index = self.selected_index();
272 let task = self
273 .matches
274 .get(current_match_index)
275 .and_then(|current_match| {
276 let ix = current_match.candidate_id;
277 self.candidates
278 .as_ref()
279 .map(|candidates| candidates[ix].clone())
280 });
281 let Some((task_source_kind, task)) = task else {
282 return;
283 };
284
285 self.workspace
286 .update(cx, |workspace, cx| {
287 schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
288 })
289 .ok();
290 cx.emit(DismissEvent);
291 }
292
293 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
294 cx.emit(DismissEvent);
295 }
296
297 fn render_match(
298 &self,
299 ix: usize,
300 selected: bool,
301 cx: &mut ViewContext<picker::Picker<Self>>,
302 ) -> Option<Self::ListItem> {
303 let candidates = self.candidates.as_ref()?;
304 let hit = &self.matches[ix];
305 let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
306 let template = resolved_task.original_task();
307 let display_label = resolved_task.display_label();
308
309 let mut tooltip_label_text = if display_label != &template.label {
310 resolved_task.resolved_label.clone()
311 } else {
312 String::new()
313 };
314 if let Some(resolved) = resolved_task.resolved.as_ref() {
315 if display_label != resolved.command_label {
316 if !tooltip_label_text.trim().is_empty() {
317 tooltip_label_text.push('\n');
318 }
319 tooltip_label_text.push_str(&resolved.command_label);
320 }
321 }
322 let tooltip_label = if tooltip_label_text.trim().is_empty() {
323 None
324 } else {
325 Some(Tooltip::text(tooltip_label_text, cx))
326 };
327
328 let highlighted_location = HighlightedText {
329 text: hit.string.clone(),
330 highlight_positions: hit.positions.clone(),
331 char_count: hit.string.chars().count(),
332 };
333 let icon = match source_kind {
334 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
335 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
336 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
337 TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
338 .get_type_icon(&name.to_lowercase())
339 .map(|icon_path| Icon::from_path(icon_path)),
340 };
341
342 Some(
343 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
344 .inset(true)
345 .spacing(ListItemSpacing::Sparse)
346 .when_some(tooltip_label, |list_item, item_label| {
347 list_item.tooltip(move |_| item_label.clone())
348 })
349 .map(|item| {
350 let item = if matches!(source_kind, TaskSourceKind::UserInput)
351 || Some(ix) <= self.last_used_candidate_index
352 {
353 let task_index = hit.candidate_id;
354 let delete_button = div().child(
355 IconButton::new("delete", IconName::Close)
356 .shape(IconButtonShape::Square)
357 .icon_color(Color::Muted)
358 .size(ButtonSize::None)
359 .icon_size(IconSize::XSmall)
360 .on_click(cx.listener(move |picker, _event, cx| {
361 cx.stop_propagation();
362 cx.prevent_default();
363
364 picker.delegate.delete_previously_used(task_index, cx);
365 picker.delegate.last_used_candidate_index = picker
366 .delegate
367 .last_used_candidate_index
368 .unwrap_or(0)
369 .checked_sub(1);
370 picker.refresh(cx);
371 }))
372 .tooltip(|cx| {
373 Tooltip::text("Delete previously scheduled task", cx)
374 }),
375 );
376 item.end_hover_slot(delete_button)
377 } else {
378 item
379 };
380 if let Some(icon) = icon {
381 item.end_slot(icon)
382 } else {
383 item
384 }
385 })
386 .selected(selected)
387 .child(highlighted_location.render(cx)),
388 )
389 }
390
391 fn selected_as_query(&self) -> Option<String> {
392 let task_index = self.matches.get(self.selected_index())?.candidate_id;
393 let tasks = self.candidates.as_ref()?;
394 let (_, task) = tasks.get(task_index)?;
395 Some(task.resolved.as_ref()?.command_label.clone())
396 }
397
398 fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
399 let Some((task_source_kind, task)) = self.spawn_oneshot() else {
400 return;
401 };
402 self.workspace
403 .update(cx, |workspace, cx| {
404 schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
405 })
406 .ok();
407 cx.emit(DismissEvent);
408 }
409
410 fn separators_after_indices(&self) -> Vec<usize> {
411 if let Some(i) = self.last_used_candidate_index {
412 vec![i]
413 } else {
414 Vec::new()
415 }
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use std::{path::PathBuf, sync::Arc};
422
423 use editor::Editor;
424 use gpui::{TestAppContext, VisualTestContext};
425 use language::{ContextProviderWithTasks, Language, LanguageConfig, LanguageMatcher, Point};
426 use project::{FakeFs, Project};
427 use serde_json::json;
428 use task::TaskTemplates;
429 use workspace::CloseInactiveTabsAndPanes;
430
431 use crate::{modal::Spawn, tests::init_test};
432
433 use super::*;
434
435 #[gpui::test]
436 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
437 init_test(cx);
438 let fs = FakeFs::new(cx.executor());
439 fs.insert_tree(
440 "/dir",
441 json!({
442 ".zed": {
443 "tasks.json": r#"[
444 {
445 "label": "example task",
446 "command": "echo",
447 "args": ["4"]
448 },
449 {
450 "label": "another one",
451 "command": "echo",
452 "args": ["55"]
453 },
454 ]"#,
455 },
456 "a.ts": "a"
457 }),
458 )
459 .await;
460
461 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
462 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
463
464 let tasks_picker = open_spawn_tasks(&workspace, cx);
465 assert_eq!(
466 query(&tasks_picker, cx),
467 "",
468 "Initial query should be empty"
469 );
470 assert_eq!(
471 task_names(&tasks_picker, cx),
472 vec!["another one", "example task"],
473 "Initial tasks should be listed in alphabetical order"
474 );
475
476 let query_str = "tas";
477 cx.simulate_input(query_str);
478 assert_eq!(query(&tasks_picker, cx), query_str);
479 assert_eq!(
480 task_names(&tasks_picker, cx),
481 vec!["example task"],
482 "Only one task should match the query {query_str}"
483 );
484
485 cx.dispatch_action(picker::UseSelectedQuery);
486 assert_eq!(
487 query(&tasks_picker, cx),
488 "echo 4",
489 "Query should be set to the selected task's command"
490 );
491 assert_eq!(
492 task_names(&tasks_picker, cx),
493 Vec::<String>::new(),
494 "No task should be listed"
495 );
496 cx.dispatch_action(picker::ConfirmInput { secondary: false });
497
498 let tasks_picker = open_spawn_tasks(&workspace, cx);
499 assert_eq!(
500 query(&tasks_picker, cx),
501 "",
502 "Query should be reset after confirming"
503 );
504 assert_eq!(
505 task_names(&tasks_picker, cx),
506 vec!["echo 4", "another one", "example task"],
507 "New oneshot task should be listed first"
508 );
509
510 let query_str = "echo 4";
511 cx.simulate_input(query_str);
512 assert_eq!(query(&tasks_picker, cx), query_str);
513 assert_eq!(
514 task_names(&tasks_picker, cx),
515 vec!["echo 4"],
516 "New oneshot should match custom command query"
517 );
518
519 cx.dispatch_action(picker::ConfirmInput { secondary: false });
520 let tasks_picker = open_spawn_tasks(&workspace, cx);
521 assert_eq!(
522 query(&tasks_picker, cx),
523 "",
524 "Query should be reset after confirming"
525 );
526 assert_eq!(
527 task_names(&tasks_picker, cx),
528 vec![query_str, "another one", "example task"],
529 "Last recently used one show task should be listed first"
530 );
531
532 cx.dispatch_action(picker::UseSelectedQuery);
533 assert_eq!(
534 query(&tasks_picker, cx),
535 query_str,
536 "Query should be set to the custom task's name"
537 );
538 assert_eq!(
539 task_names(&tasks_picker, cx),
540 vec![query_str],
541 "Only custom task should be listed"
542 );
543
544 let query_str = "0";
545 cx.simulate_input(query_str);
546 assert_eq!(query(&tasks_picker, cx), "echo 40");
547 assert_eq!(
548 task_names(&tasks_picker, cx),
549 Vec::<String>::new(),
550 "New oneshot should not match any command query"
551 );
552
553 cx.dispatch_action(picker::ConfirmInput { secondary: true });
554 let tasks_picker = open_spawn_tasks(&workspace, cx);
555 assert_eq!(
556 query(&tasks_picker, cx),
557 "",
558 "Query should be reset after confirming"
559 );
560 assert_eq!(
561 task_names(&tasks_picker, cx),
562 vec!["echo 4", "another one", "example task"],
563 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
564 );
565
566 cx.dispatch_action(Spawn {
567 task_name: Some("example task".to_string()),
568 });
569 let tasks_picker = workspace.update(cx, |workspace, cx| {
570 workspace
571 .active_modal::<TasksModal>(cx)
572 .unwrap()
573 .read(cx)
574 .picker
575 .clone()
576 });
577 assert_eq!(
578 task_names(&tasks_picker, cx),
579 vec!["echo 4", "another one", "example task"],
580 );
581 }
582
583 #[gpui::test]
584 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
585 init_test(cx);
586 let fs = FakeFs::new(cx.executor());
587 fs.insert_tree(
588 "/dir",
589 json!({
590 ".zed": {
591 "tasks.json": r#"[
592 {
593 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
594 "command": "echo",
595 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
596 },
597 {
598 "label": "opened now: $ZED_WORKTREE_ROOT",
599 "command": "echo",
600 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
601 }
602 ]"#,
603 },
604 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
605 "file_with.odd_extension": "b",
606 }),
607 )
608 .await;
609
610 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
611 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
612
613 let tasks_picker = open_spawn_tasks(&workspace, cx);
614 assert_eq!(
615 task_names(&tasks_picker, cx),
616 Vec::<String>::new(),
617 "Should list no file or worktree context-dependent when no file is open"
618 );
619 tasks_picker.update(cx, |_, cx| {
620 cx.emit(DismissEvent);
621 });
622 drop(tasks_picker);
623 cx.executor().run_until_parked();
624
625 let _ = workspace
626 .update(cx, |workspace, cx| {
627 workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
628 })
629 .await
630 .unwrap();
631 cx.executor().run_until_parked();
632 let tasks_picker = open_spawn_tasks(&workspace, cx);
633 assert_eq!(
634 task_names(&tasks_picker, cx),
635 vec![
636 "hello from …th.odd_extension:1:1".to_string(),
637 "opened now: /dir".to_string()
638 ],
639 "Second opened buffer should fill the context, labels should be trimmed if long enough"
640 );
641 tasks_picker.update(cx, |_, cx| {
642 cx.emit(DismissEvent);
643 });
644 drop(tasks_picker);
645 cx.executor().run_until_parked();
646
647 let second_item = workspace
648 .update(cx, |workspace, cx| {
649 workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
650 })
651 .await
652 .unwrap();
653
654 let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
655 editor.update(cx, |editor, cx| {
656 editor.change_selections(None, cx, |s| {
657 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
658 })
659 });
660 cx.executor().run_until_parked();
661 let tasks_picker = open_spawn_tasks(&workspace, cx);
662 assert_eq!(
663 task_names(&tasks_picker, cx),
664 vec![
665 "hello from …ithout_extension:2:3".to_string(),
666 "opened now: /dir".to_string()
667 ],
668 "Opened buffer should fill the context, labels should be trimmed if long enough"
669 );
670 tasks_picker.update(cx, |_, cx| {
671 cx.emit(DismissEvent);
672 });
673 drop(tasks_picker);
674 cx.executor().run_until_parked();
675 }
676
677 #[gpui::test]
678 async fn test_language_task_filtering(cx: &mut TestAppContext) {
679 init_test(cx);
680 let fs = FakeFs::new(cx.executor());
681 fs.insert_tree(
682 "/dir",
683 json!({
684 "a1.ts": "// a1",
685 "a2.ts": "// a2",
686 "b.rs": "// b",
687 }),
688 )
689 .await;
690
691 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
692 project.read_with(cx, |project, _| {
693 let language_registry = project.languages();
694 language_registry.add(Arc::new(
695 Language::new(
696 LanguageConfig {
697 name: "TypeScript".into(),
698 matcher: LanguageMatcher {
699 path_suffixes: vec!["ts".to_string()],
700 ..LanguageMatcher::default()
701 },
702 ..LanguageConfig::default()
703 },
704 None,
705 )
706 .with_context_provider(Some(Arc::new(
707 ContextProviderWithTasks::new(TaskTemplates(vec![
708 TaskTemplate {
709 label: "Task without variables".to_string(),
710 command: "npm run clean".to_string(),
711 ..TaskTemplate::default()
712 },
713 TaskTemplate {
714 label: "TypeScript task from file $ZED_FILE".to_string(),
715 command: "npm run build".to_string(),
716 ..TaskTemplate::default()
717 },
718 TaskTemplate {
719 label: "Another task from file $ZED_FILE".to_string(),
720 command: "npm run lint".to_string(),
721 ..TaskTemplate::default()
722 },
723 ])),
724 ))),
725 ));
726 language_registry.add(Arc::new(
727 Language::new(
728 LanguageConfig {
729 name: "Rust".into(),
730 matcher: LanguageMatcher {
731 path_suffixes: vec!["rs".to_string()],
732 ..LanguageMatcher::default()
733 },
734 ..LanguageConfig::default()
735 },
736 None,
737 )
738 .with_context_provider(Some(Arc::new(
739 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
740 label: "Rust task".to_string(),
741 command: "cargo check".into(),
742 ..TaskTemplate::default()
743 }])),
744 ))),
745 ));
746 });
747 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
748
749 let _ts_file_1 = workspace
750 .update(cx, |workspace, cx| {
751 workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
752 })
753 .await
754 .unwrap();
755 let tasks_picker = open_spawn_tasks(&workspace, cx);
756 assert_eq!(
757 task_names(&tasks_picker, cx),
758 vec![
759 "Another task from file /dir/a1.ts",
760 "TypeScript task from file /dir/a1.ts",
761 "Task without variables",
762 ],
763 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
764 );
765 emulate_task_schedule(
766 tasks_picker,
767 &project,
768 "TypeScript task from file /dir/a1.ts",
769 cx,
770 );
771
772 let tasks_picker = open_spawn_tasks(&workspace, cx);
773 assert_eq!(
774 task_names(&tasks_picker, cx),
775 vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
776 "After spawning the task and getting it into the history, it should be up in the sort as recently used"
777 );
778 tasks_picker.update(cx, |_, cx| {
779 cx.emit(DismissEvent);
780 });
781 drop(tasks_picker);
782 cx.executor().run_until_parked();
783
784 let _ts_file_2 = workspace
785 .update(cx, |workspace, cx| {
786 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
787 })
788 .await
789 .unwrap();
790 let tasks_picker = open_spawn_tasks(&workspace, cx);
791 assert_eq!(
792 task_names(&tasks_picker, cx),
793 vec![
794 "TypeScript task from file /dir/a1.ts",
795 "Another task from file /dir/a2.ts",
796 "TypeScript task from file /dir/a2.ts",
797 "Task without variables"
798 ],
799 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
800 );
801 tasks_picker.update(cx, |_, cx| {
802 cx.emit(DismissEvent);
803 });
804 drop(tasks_picker);
805 cx.executor().run_until_parked();
806
807 let _rs_file = workspace
808 .update(cx, |workspace, cx| {
809 workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
810 })
811 .await
812 .unwrap();
813 let tasks_picker = open_spawn_tasks(&workspace, cx);
814 assert_eq!(
815 task_names(&tasks_picker, cx),
816 vec!["Rust task"],
817 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
818 );
819
820 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
821 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
822 let _ts_file_2 = workspace
823 .update(cx, |workspace, cx| {
824 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
825 })
826 .await
827 .unwrap();
828 let tasks_picker = open_spawn_tasks(&workspace, cx);
829 assert_eq!(
830 task_names(&tasks_picker, cx),
831 vec![
832 "TypeScript task from file /dir/a1.ts",
833 "Another task from file /dir/a2.ts",
834 "TypeScript task from file /dir/a2.ts",
835 "Task without variables"
836 ],
837 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
838 same TS spawn history should be restored"
839 );
840 }
841
842 fn emulate_task_schedule(
843 tasks_picker: View<Picker<TasksModalDelegate>>,
844 project: &Model<Project>,
845 scheduled_task_label: &str,
846 cx: &mut VisualTestContext,
847 ) {
848 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
849 tasks_picker
850 .delegate
851 .candidates
852 .iter()
853 .flatten()
854 .find(|(_, task)| task.resolved_label == scheduled_task_label)
855 .cloned()
856 .unwrap()
857 });
858 project.update(cx, |project, cx| {
859 project.task_inventory().update(cx, |inventory, _| {
860 let (kind, task) = scheduled_task;
861 inventory.task_scheduled(kind, task);
862 })
863 });
864 tasks_picker.update(cx, |_, cx| {
865 cx.emit(DismissEvent);
866 });
867 drop(tasks_picker);
868 cx.executor().run_until_parked()
869 }
870
871 fn open_spawn_tasks(
872 workspace: &View<Workspace>,
873 cx: &mut VisualTestContext,
874 ) -> View<Picker<TasksModalDelegate>> {
875 cx.dispatch_action(Spawn::default());
876 workspace.update(cx, |workspace, cx| {
877 workspace
878 .active_modal::<TasksModal>(cx)
879 .expect("no task modal after `Spawn` action was dispatched")
880 .read(cx)
881 .picker
882 .clone()
883 })
884 }
885
886 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
887 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
888 }
889
890 fn task_names(
891 spawn_tasks: &View<Picker<TasksModalDelegate>>,
892 cx: &mut VisualTestContext,
893 ) -> Vec<String> {
894 spawn_tasks.update(cx, |spawn_tasks, _| {
895 spawn_tasks
896 .delegate
897 .matches
898 .iter()
899 .map(|hit| hit.string.clone())
900 .collect::<Vec<_>>()
901 })
902 }
903}