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