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