modal.rs

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