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