modal.rs

  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}