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 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}