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