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
237 .resolved
238 .as_ref()
239 .map(|resolved| resolved.label.clone())
240 .unwrap_or_else(|| candidate.resolved_label.clone()),
241 })
242 .collect::<Vec<_>>()
243 })
244 .ok()
245 else {
246 return;
247 };
248 let matches = fuzzy::match_strings(
249 &candidates,
250 &query,
251 true,
252 1000,
253 &Default::default(),
254 cx.background_executor().clone(),
255 )
256 .await;
257 picker
258 .update(&mut cx, |picker, _| {
259 let delegate = &mut picker.delegate;
260 delegate.matches = matches;
261 delegate.prompt = query;
262
263 if delegate.matches.is_empty() {
264 delegate.selected_index = 0;
265 } else {
266 delegate.selected_index =
267 delegate.selected_index.min(delegate.matches.len() - 1);
268 }
269 })
270 .log_err();
271 })
272 }
273
274 fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
275 let current_match_index = self.selected_index();
276 let task = self
277 .matches
278 .get(current_match_index)
279 .and_then(|current_match| {
280 let ix = current_match.candidate_id;
281 self.candidates
282 .as_ref()
283 .map(|candidates| candidates[ix].clone())
284 });
285 let Some((task_source_kind, task)) = task else {
286 return;
287 };
288
289 self.workspace
290 .update(cx, |workspace, cx| {
291 schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
292 })
293 .ok();
294 cx.emit(DismissEvent);
295 }
296
297 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
298 cx.emit(DismissEvent);
299 }
300
301 fn render_match(
302 &self,
303 ix: usize,
304 selected: bool,
305 cx: &mut ViewContext<picker::Picker<Self>>,
306 ) -> Option<Self::ListItem> {
307 let candidates = self.candidates.as_ref()?;
308 let hit = &self.matches[ix];
309 let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
310 let template = resolved_task.original_task();
311 let display_label = resolved_task.display_label();
312
313 let mut tooltip_label_text = if display_label != &template.label {
314 resolved_task.resolved_label.clone()
315 } else {
316 String::new()
317 };
318 if let Some(resolved) = resolved_task.resolved.as_ref() {
319 if display_label != resolved.command_label {
320 if !tooltip_label_text.trim().is_empty() {
321 tooltip_label_text.push('\n');
322 }
323 tooltip_label_text.push_str(&resolved.command_label);
324 }
325 }
326 let tooltip_label = if tooltip_label_text.trim().is_empty() {
327 None
328 } else {
329 Some(Tooltip::text(tooltip_label_text, cx))
330 };
331
332 let highlighted_location = HighlightedText {
333 text: hit.string.clone(),
334 highlight_positions: hit.positions.clone(),
335 char_count: hit.string.chars().count(),
336 };
337 let icon = match source_kind {
338 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
339 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
340 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
341 TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
342 .get_type_icon(&name.to_lowercase())
343 .map(|icon_path| Icon::from_path(icon_path)),
344 };
345 Some(
346 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
347 .inset(true)
348 .spacing(ListItemSpacing::Sparse)
349 .when_some(tooltip_label, |list_item, item_label| {
350 list_item.tooltip(move |_| item_label.clone())
351 })
352 .map(|item| {
353 let item = if matches!(source_kind, TaskSourceKind::UserInput)
354 || Some(ix) <= self.last_used_candidate_index
355 {
356 let task_index = hit.candidate_id;
357 let delete_button = div().child(
358 IconButton::new("delete", IconName::Close)
359 .shape(IconButtonShape::Square)
360 .icon_color(Color::Muted)
361 .size(ButtonSize::None)
362 .icon_size(IconSize::XSmall)
363 .on_click(cx.listener(move |picker, _event, cx| {
364 cx.stop_propagation();
365 cx.prevent_default();
366
367 picker.delegate.delete_previously_used(task_index, cx);
368 picker.delegate.last_used_candidate_index = picker
369 .delegate
370 .last_used_candidate_index
371 .unwrap_or(0)
372 .checked_sub(1);
373 picker.refresh(cx);
374 }))
375 .tooltip(|cx| {
376 Tooltip::text("Delete previously scheduled task", cx)
377 }),
378 );
379 item.end_hover_slot(delete_button)
380 } else {
381 item
382 };
383 if let Some(icon) = icon {
384 item.end_slot(icon)
385 } else {
386 item
387 }
388 })
389 .selected(selected)
390 .child(highlighted_location.render(cx)),
391 )
392 }
393
394 fn selected_as_query(&self) -> Option<String> {
395 let task_index = self.matches.get(self.selected_index())?.candidate_id;
396 let tasks = self.candidates.as_ref()?;
397 let (_, task) = tasks.get(task_index)?;
398 Some(task.resolved.as_ref()?.command_label.clone())
399 }
400
401 fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
402 let Some((task_source_kind, task)) = self.spawn_oneshot() else {
403 return;
404 };
405 self.workspace
406 .update(cx, |workspace, cx| {
407 schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
408 })
409 .ok();
410 cx.emit(DismissEvent);
411 }
412
413 fn separators_after_indices(&self) -> Vec<usize> {
414 if let Some(i) = self.last_used_candidate_index {
415 vec![i]
416 } else {
417 Vec::new()
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use std::{path::PathBuf, sync::Arc};
425
426 use editor::Editor;
427 use gpui::{TestAppContext, VisualTestContext};
428 use language::{ContextProviderWithTasks, Language, LanguageConfig, LanguageMatcher, Point};
429 use project::{FakeFs, Project};
430 use serde_json::json;
431 use task::TaskTemplates;
432 use workspace::CloseInactiveTabsAndPanes;
433
434 use crate::{modal::Spawn, tests::init_test};
435
436 use super::*;
437
438 #[gpui::test]
439 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
440 init_test(cx);
441 let fs = FakeFs::new(cx.executor());
442 fs.insert_tree(
443 "/dir",
444 json!({
445 ".zed": {
446 "tasks.json": r#"[
447 {
448 "label": "example task",
449 "command": "echo",
450 "args": ["4"]
451 },
452 {
453 "label": "another one",
454 "command": "echo",
455 "args": ["55"]
456 },
457 ]"#,
458 },
459 "a.ts": "a"
460 }),
461 )
462 .await;
463
464 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
465 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
466
467 let tasks_picker = open_spawn_tasks(&workspace, cx);
468 assert_eq!(
469 query(&tasks_picker, cx),
470 "",
471 "Initial query should be empty"
472 );
473 assert_eq!(
474 task_names(&tasks_picker, cx),
475 vec!["another one", "example task"],
476 "Initial tasks should be listed in alphabetical order"
477 );
478
479 let query_str = "tas";
480 cx.simulate_input(query_str);
481 assert_eq!(query(&tasks_picker, cx), query_str);
482 assert_eq!(
483 task_names(&tasks_picker, cx),
484 vec!["example task"],
485 "Only one task should match the query {query_str}"
486 );
487
488 cx.dispatch_action(picker::UseSelectedQuery);
489 assert_eq!(
490 query(&tasks_picker, cx),
491 "echo 4",
492 "Query should be set to the selected task's command"
493 );
494 assert_eq!(
495 task_names(&tasks_picker, cx),
496 Vec::<String>::new(),
497 "No task should be listed"
498 );
499 cx.dispatch_action(picker::ConfirmInput { secondary: false });
500
501 let tasks_picker = open_spawn_tasks(&workspace, cx);
502 assert_eq!(
503 query(&tasks_picker, cx),
504 "",
505 "Query should be reset after confirming"
506 );
507 assert_eq!(
508 task_names(&tasks_picker, cx),
509 vec!["echo 4", "another one", "example task"],
510 "New oneshot task should be listed first"
511 );
512
513 let query_str = "echo 4";
514 cx.simulate_input(query_str);
515 assert_eq!(query(&tasks_picker, cx), query_str);
516 assert_eq!(
517 task_names(&tasks_picker, cx),
518 vec!["echo 4"],
519 "New oneshot should match custom command query"
520 );
521
522 cx.dispatch_action(picker::ConfirmInput { secondary: false });
523 let tasks_picker = open_spawn_tasks(&workspace, cx);
524 assert_eq!(
525 query(&tasks_picker, cx),
526 "",
527 "Query should be reset after confirming"
528 );
529 assert_eq!(
530 task_names(&tasks_picker, cx),
531 vec![query_str, "another one", "example task"],
532 "Last recently used one show task should be listed first"
533 );
534
535 cx.dispatch_action(picker::UseSelectedQuery);
536 assert_eq!(
537 query(&tasks_picker, cx),
538 query_str,
539 "Query should be set to the custom task's name"
540 );
541 assert_eq!(
542 task_names(&tasks_picker, cx),
543 vec![query_str],
544 "Only custom task should be listed"
545 );
546
547 let query_str = "0";
548 cx.simulate_input(query_str);
549 assert_eq!(query(&tasks_picker, cx), "echo 40");
550 assert_eq!(
551 task_names(&tasks_picker, cx),
552 Vec::<String>::new(),
553 "New oneshot should not match any command query"
554 );
555
556 cx.dispatch_action(picker::ConfirmInput { secondary: true });
557 let tasks_picker = open_spawn_tasks(&workspace, cx);
558 assert_eq!(
559 query(&tasks_picker, cx),
560 "",
561 "Query should be reset after confirming"
562 );
563 assert_eq!(
564 task_names(&tasks_picker, cx),
565 vec!["echo 4", "another one", "example task"],
566 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
567 );
568
569 cx.dispatch_action(Spawn {
570 task_name: Some("example task".to_string()),
571 });
572 let tasks_picker = workspace.update(cx, |workspace, cx| {
573 workspace
574 .active_modal::<TasksModal>(cx)
575 .unwrap()
576 .read(cx)
577 .picker
578 .clone()
579 });
580 assert_eq!(
581 task_names(&tasks_picker, cx),
582 vec!["echo 4", "another one", "example task"],
583 );
584 }
585
586 #[gpui::test]
587 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
588 init_test(cx);
589 let fs = FakeFs::new(cx.executor());
590 fs.insert_tree(
591 "/dir",
592 json!({
593 ".zed": {
594 "tasks.json": r#"[
595 {
596 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
597 "command": "echo",
598 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
599 },
600 {
601 "label": "opened now: $ZED_WORKTREE_ROOT",
602 "command": "echo",
603 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
604 }
605 ]"#,
606 },
607 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
608 "file_with.odd_extension": "b",
609 }),
610 )
611 .await;
612
613 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
614 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
615
616 let tasks_picker = open_spawn_tasks(&workspace, cx);
617 assert_eq!(
618 task_names(&tasks_picker, cx),
619 Vec::<String>::new(),
620 "Should list no file or worktree context-dependent when no file is open"
621 );
622 tasks_picker.update(cx, |_, cx| {
623 cx.emit(DismissEvent);
624 });
625 drop(tasks_picker);
626 cx.executor().run_until_parked();
627
628 let _ = workspace
629 .update(cx, |workspace, cx| {
630 workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
631 })
632 .await
633 .unwrap();
634 cx.executor().run_until_parked();
635 let tasks_picker = open_spawn_tasks(&workspace, cx);
636 assert_eq!(
637 task_names(&tasks_picker, cx),
638 vec![
639 "hello from …th.odd_extension:1:1".to_string(),
640 "opened now: /dir".to_string()
641 ],
642 "Second opened buffer should fill the context, labels should be trimmed if long enough"
643 );
644 tasks_picker.update(cx, |_, cx| {
645 cx.emit(DismissEvent);
646 });
647 drop(tasks_picker);
648 cx.executor().run_until_parked();
649
650 let second_item = workspace
651 .update(cx, |workspace, cx| {
652 workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
653 })
654 .await
655 .unwrap();
656
657 let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
658 editor.update(cx, |editor, cx| {
659 editor.change_selections(None, cx, |s| {
660 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
661 })
662 });
663 cx.executor().run_until_parked();
664 let tasks_picker = open_spawn_tasks(&workspace, cx);
665 assert_eq!(
666 task_names(&tasks_picker, cx),
667 vec![
668 "hello from …ithout_extension:2:3".to_string(),
669 "opened now: /dir".to_string()
670 ],
671 "Opened buffer should fill the context, labels should be trimmed if long enough"
672 );
673 tasks_picker.update(cx, |_, cx| {
674 cx.emit(DismissEvent);
675 });
676 drop(tasks_picker);
677 cx.executor().run_until_parked();
678 }
679
680 #[gpui::test]
681 async fn test_language_task_filtering(cx: &mut TestAppContext) {
682 init_test(cx);
683 let fs = FakeFs::new(cx.executor());
684 fs.insert_tree(
685 "/dir",
686 json!({
687 "a1.ts": "// a1",
688 "a2.ts": "// a2",
689 "b.rs": "// b",
690 }),
691 )
692 .await;
693
694 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
695 project.read_with(cx, |project, _| {
696 let language_registry = project.languages();
697 language_registry.add(Arc::new(
698 Language::new(
699 LanguageConfig {
700 name: "TypeScript".into(),
701 matcher: LanguageMatcher {
702 path_suffixes: vec!["ts".to_string()],
703 ..LanguageMatcher::default()
704 },
705 ..LanguageConfig::default()
706 },
707 None,
708 )
709 .with_context_provider(Some(Arc::new(
710 ContextProviderWithTasks::new(TaskTemplates(vec![
711 TaskTemplate {
712 label: "Task without variables".to_string(),
713 command: "npm run clean".to_string(),
714 ..TaskTemplate::default()
715 },
716 TaskTemplate {
717 label: "TypeScript task from file $ZED_FILE".to_string(),
718 command: "npm run build".to_string(),
719 ..TaskTemplate::default()
720 },
721 TaskTemplate {
722 label: "Another task from file $ZED_FILE".to_string(),
723 command: "npm run lint".to_string(),
724 ..TaskTemplate::default()
725 },
726 ])),
727 ))),
728 ));
729 language_registry.add(Arc::new(
730 Language::new(
731 LanguageConfig {
732 name: "Rust".into(),
733 matcher: LanguageMatcher {
734 path_suffixes: vec!["rs".to_string()],
735 ..LanguageMatcher::default()
736 },
737 ..LanguageConfig::default()
738 },
739 None,
740 )
741 .with_context_provider(Some(Arc::new(
742 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
743 label: "Rust task".to_string(),
744 command: "cargo check".into(),
745 ..TaskTemplate::default()
746 }])),
747 ))),
748 ));
749 });
750 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
751
752 let _ts_file_1 = workspace
753 .update(cx, |workspace, cx| {
754 workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
755 })
756 .await
757 .unwrap();
758 let tasks_picker = open_spawn_tasks(&workspace, cx);
759 assert_eq!(
760 task_names(&tasks_picker, cx),
761 vec![
762 "Another task from file /dir/a1.ts",
763 "TypeScript task from file /dir/a1.ts",
764 "Task without variables",
765 ],
766 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
767 );
768 emulate_task_schedule(
769 tasks_picker,
770 &project,
771 "TypeScript task from file /dir/a1.ts",
772 cx,
773 );
774
775 let tasks_picker = open_spawn_tasks(&workspace, cx);
776 assert_eq!(
777 task_names(&tasks_picker, cx),
778 vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
779 "After spawning the task and getting it into the history, it should be up in the sort as recently used"
780 );
781 tasks_picker.update(cx, |_, cx| {
782 cx.emit(DismissEvent);
783 });
784 drop(tasks_picker);
785 cx.executor().run_until_parked();
786
787 let _ts_file_2 = workspace
788 .update(cx, |workspace, cx| {
789 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
790 })
791 .await
792 .unwrap();
793 let tasks_picker = open_spawn_tasks(&workspace, cx);
794 assert_eq!(
795 task_names(&tasks_picker, cx),
796 vec![
797 "TypeScript task from file /dir/a1.ts",
798 "Another task from file /dir/a2.ts",
799 "TypeScript task from file /dir/a2.ts",
800 "Task without variables"
801 ],
802 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
803 );
804 tasks_picker.update(cx, |_, cx| {
805 cx.emit(DismissEvent);
806 });
807 drop(tasks_picker);
808 cx.executor().run_until_parked();
809
810 let _rs_file = workspace
811 .update(cx, |workspace, cx| {
812 workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
813 })
814 .await
815 .unwrap();
816 let tasks_picker = open_spawn_tasks(&workspace, cx);
817 assert_eq!(
818 task_names(&tasks_picker, cx),
819 vec!["Rust task"],
820 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
821 );
822
823 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
824 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
825 let _ts_file_2 = workspace
826 .update(cx, |workspace, cx| {
827 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
828 })
829 .await
830 .unwrap();
831 let tasks_picker = open_spawn_tasks(&workspace, cx);
832 assert_eq!(
833 task_names(&tasks_picker, cx),
834 vec![
835 "TypeScript task from file /dir/a1.ts",
836 "Another task from file /dir/a2.ts",
837 "TypeScript task from file /dir/a2.ts",
838 "Task without variables"
839 ],
840 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
841 same TS spawn history should be restored"
842 );
843 }
844
845 fn emulate_task_schedule(
846 tasks_picker: View<Picker<TasksModalDelegate>>,
847 project: &Model<Project>,
848 scheduled_task_label: &str,
849 cx: &mut VisualTestContext,
850 ) {
851 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
852 tasks_picker
853 .delegate
854 .candidates
855 .iter()
856 .flatten()
857 .find(|(_, task)| task.resolved_label == scheduled_task_label)
858 .cloned()
859 .unwrap()
860 });
861 project.update(cx, |project, cx| {
862 project.task_inventory().update(cx, |inventory, _| {
863 let (kind, task) = scheduled_task;
864 inventory.task_scheduled(kind, task);
865 })
866 });
867 tasks_picker.update(cx, |_, cx| {
868 cx.emit(DismissEvent);
869 });
870 drop(tasks_picker);
871 cx.executor().run_until_parked()
872 }
873
874 fn open_spawn_tasks(
875 workspace: &View<Workspace>,
876 cx: &mut VisualTestContext,
877 ) -> View<Picker<TasksModalDelegate>> {
878 cx.dispatch_action(Spawn::default());
879 workspace.update(cx, |workspace, cx| {
880 workspace
881 .active_modal::<TasksModal>(cx)
882 .expect("no task modal after `Spawn` action was dispatched")
883 .read(cx)
884 .picker
885 .clone()
886 })
887 }
888
889 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
890 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
891 }
892
893 fn task_names(
894 spawn_tasks: &View<Picker<TasksModalDelegate>>,
895 cx: &mut VisualTestContext,
896 ) -> Vec<String> {
897 spawn_tasks.update(cx, |spawn_tasks, _| {
898 spawn_tasks
899 .delegate
900 .matches
901 .iter()
902 .map(|hit| hit.string.clone())
903 .collect::<Vec<_>>()
904 })
905 }
906}