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