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