modal.rs

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