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