modal.rs

   1use std::sync::Arc;
   2
   3use crate::active_item_selection_properties;
   4use fuzzy::{StringMatch, StringMatchCandidate};
   5use gpui::{
   6    impl_actions, rems, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusableView,
   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, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color,
  15    FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement,
  16    KeyBinding, LabelSize, ListItem, ListItemSpacing, RenderOnce, Selectable, Tooltip,
  17    WindowContext,
  18};
  19use util::ResultExt;
  20use workspace::{tasks::schedule_resolved_task, ModalView, Workspace};
  21
  22use serde::Deserialize;
  23
  24/// Spawn a task with name or open tasks modal
  25#[derive(PartialEq, Clone, Deserialize, Default)]
  26pub struct Spawn {
  27    #[serde(default)]
  28    /// Name of the task to spawn.
  29    /// If it is not set, a modal with a list of available tasks is opened instead.
  30    /// Defaults to None.
  31    pub task_name: Option<String>,
  32}
  33
  34impl Spawn {
  35    pub fn modal() -> Self {
  36        Self { task_name: None }
  37    }
  38}
  39
  40/// Rerun last task
  41#[derive(PartialEq, Clone, Deserialize, Default)]
  42pub struct Rerun {
  43    /// Controls whether the task context is reevaluated prior to execution of a task.
  44    /// 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
  45    /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
  46    /// default: false
  47    #[serde(default)]
  48    pub reevaluate_context: bool,
  49    /// Overrides `allow_concurrent_runs` property of the task being reran.
  50    /// Default: null
  51    #[serde(default)]
  52    pub allow_concurrent_runs: Option<bool>,
  53    /// Overrides `use_new_terminal` property of the task being reran.
  54    /// Default: null
  55    #[serde(default)]
  56    pub use_new_terminal: Option<bool>,
  57}
  58
  59impl_actions!(task, [Rerun, Spawn]);
  60
  61/// A modal used to spawn new tasks.
  62pub(crate) struct TasksModalDelegate {
  63    inventory: Model<Inventory>,
  64    candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
  65    last_used_candidate_index: Option<usize>,
  66    divider_index: Option<usize>,
  67    matches: Vec<StringMatch>,
  68    selected_index: usize,
  69    workspace: WeakView<Workspace>,
  70    prompt: String,
  71    task_context: TaskContext,
  72    placeholder_text: Arc<str>,
  73}
  74
  75impl TasksModalDelegate {
  76    fn new(
  77        inventory: Model<Inventory>,
  78        task_context: TaskContext,
  79        workspace: WeakView<Workspace>,
  80    ) -> Self {
  81        Self {
  82            inventory,
  83            workspace,
  84            candidates: None,
  85            matches: Vec::new(),
  86            last_used_candidate_index: None,
  87            divider_index: None,
  88            selected_index: 0,
  89            prompt: String::default(),
  90            task_context,
  91            placeholder_text: Arc::from("Find a task, or run a command"),
  92        }
  93    }
  94
  95    fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
  96        if self.prompt.trim().is_empty() {
  97            return None;
  98        }
  99
 100        let source_kind = TaskSourceKind::UserInput;
 101        let id_base = source_kind.to_id_base();
 102        let new_oneshot = TaskTemplate {
 103            label: self.prompt.clone(),
 104            command: self.prompt.clone(),
 105            ..TaskTemplate::default()
 106        };
 107        Some((
 108            source_kind,
 109            new_oneshot.resolve_task(&id_base, &self.task_context)?,
 110        ))
 111    }
 112
 113    fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
 114        let Some(candidates) = self.candidates.as_mut() else {
 115            return;
 116        };
 117        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
 118            return;
 119        };
 120        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
 121        // 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
 122        // the original list without a removed entry.
 123        candidates.remove(ix);
 124        self.inventory.update(cx, |inventory, _| {
 125            inventory.delete_previously_used(&task.id);
 126        });
 127    }
 128}
 129
 130pub(crate) struct TasksModal {
 131    picker: View<Picker<TasksModalDelegate>>,
 132    _subscription: Subscription,
 133}
 134
 135impl TasksModal {
 136    pub(crate) fn new(
 137        inventory: Model<Inventory>,
 138        task_context: TaskContext,
 139        workspace: WeakView<Workspace>,
 140        cx: &mut ViewContext<Self>,
 141    ) -> Self {
 142        let picker = cx.new_view(|cx| {
 143            Picker::uniform_list(
 144                TasksModalDelegate::new(inventory, task_context, workspace),
 145                cx,
 146            )
 147        });
 148        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
 149            cx.emit(DismissEvent);
 150        });
 151        Self {
 152            picker,
 153            _subscription,
 154        }
 155    }
 156}
 157
 158impl Render for TasksModal {
 159    fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
 160        v_flex()
 161            .key_context("TasksModal")
 162            .w(rems(34.))
 163            .child(self.picker.clone())
 164    }
 165}
 166
 167impl EventEmitter<DismissEvent> for TasksModal {}
 168
 169impl FocusableView for TasksModal {
 170    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
 171        self.picker.read(cx).focus_handle(cx)
 172    }
 173}
 174
 175impl ModalView for TasksModal {}
 176
 177impl PickerDelegate for TasksModalDelegate {
 178    type ListItem = ListItem;
 179
 180    fn match_count(&self) -> usize {
 181        self.matches.len()
 182    }
 183
 184    fn selected_index(&self) -> usize {
 185        self.selected_index
 186    }
 187
 188    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
 189        self.selected_index = ix;
 190    }
 191
 192    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
 193        self.placeholder_text.clone()
 194    }
 195
 196    fn update_matches(
 197        &mut self,
 198        query: String,
 199        cx: &mut ViewContext<picker::Picker<Self>>,
 200    ) -> gpui::Task<()> {
 201        cx.spawn(move |picker, mut cx| async move {
 202            let Some(candidates) = picker
 203                .update(&mut cx, |picker, cx| {
 204                    let candidates = match &mut picker.delegate.candidates {
 205                        Some(candidates) => candidates,
 206                        None => {
 207                            let Ok((worktree, language)) =
 208                                picker.delegate.workspace.update(cx, |workspace, cx| {
 209                                    active_item_selection_properties(workspace, cx)
 210                                })
 211                            else {
 212                                return Vec::new();
 213                            };
 214                            let (used, current) =
 215                                picker.delegate.inventory.update(cx, |inventory, _| {
 216                                    inventory.used_and_current_resolved_tasks(
 217                                        language,
 218                                        worktree,
 219                                        &picker.delegate.task_context,
 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        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 358        let history_run_icon = if Some(ix) <= self.divider_index {
 359            Some(
 360                Icon::new(IconName::HistoryRerun)
 361                    .color(Color::Muted)
 362                    .size(IconSize::Small)
 363                    .into_any_element(),
 364            )
 365        } else {
 366            Some(
 367                v_flex()
 368                    .flex_none()
 369                    .size(IconSize::Small.rems())
 370                    .into_any_element(),
 371            )
 372        };
 373
 374        Some(
 375            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 376                .inset(false)
 377                .start_slot::<Icon>(icon)
 378                .end_slot::<AnyElement>(history_run_icon)
 379                .spacing(ListItemSpacing::Sparse)
 380                // .map(|this| {
 381                //     if Some(ix) <= self.divider_index {
 382                //         this.start_slot(Icon::new(IconName::HistoryRerun).size(IconSize::Small))
 383                //     } else {
 384                //         this.start_slot(v_flex().flex_none().size(IconSize::Small.rems()))
 385                //     }
 386                // })
 387                .when_some(tooltip_label, |list_item, item_label| {
 388                    list_item.tooltip(move |_| item_label.clone())
 389                })
 390                .map(|item| {
 391                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
 392                        || Some(ix) <= self.divider_index
 393                    {
 394                        let task_index = hit.candidate_id;
 395                        let delete_button = div().child(
 396                            IconButton::new("delete", IconName::Close)
 397                                .shape(IconButtonShape::Square)
 398                                .icon_color(Color::Muted)
 399                                .size(ButtonSize::None)
 400                                .icon_size(IconSize::XSmall)
 401                                .on_click(cx.listener(move |picker, _event, cx| {
 402                                    cx.stop_propagation();
 403                                    cx.prevent_default();
 404
 405                                    picker.delegate.delete_previously_used(task_index, cx);
 406                                    picker.delegate.last_used_candidate_index = picker
 407                                        .delegate
 408                                        .last_used_candidate_index
 409                                        .unwrap_or(0)
 410                                        .checked_sub(1);
 411                                    picker.refresh(cx);
 412                                }))
 413                                .tooltip(|cx| {
 414                                    Tooltip::text("Delete previously scheduled task", cx)
 415                                }),
 416                        );
 417                        item.end_hover_slot(delete_button)
 418                    } else {
 419                        item
 420                    };
 421                    item
 422                })
 423                .selected(selected)
 424                .child(highlighted_location.render(cx)),
 425        )
 426    }
 427
 428    fn selected_as_query(&self) -> Option<String> {
 429        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 430        let tasks = self.candidates.as_ref()?;
 431        let (_, task) = tasks.get(task_index)?;
 432        Some(task.resolved.as_ref()?.command_label.clone())
 433    }
 434
 435    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
 436        let Some((task_source_kind, task)) = self.spawn_oneshot() else {
 437            return;
 438        };
 439        self.workspace
 440            .update(cx, |workspace, cx| {
 441                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
 442            })
 443            .ok();
 444        cx.emit(DismissEvent);
 445    }
 446
 447    fn separators_after_indices(&self) -> Vec<usize> {
 448        if let Some(i) = self.divider_index {
 449            vec![i]
 450        } else {
 451            Vec::new()
 452        }
 453    }
 454    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<gpui::AnyElement> {
 455        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 456        let current_modifiers = cx.modifiers();
 457        Some(
 458            h_flex()
 459                .w_full()
 460                .h_8()
 461                .p_2()
 462                .justify_between()
 463                .rounded_b_md()
 464                .bg(cx.theme().colors().ghost_element_selected)
 465                .children(
 466                    KeyBinding::for_action(&picker::UseSelectedQuery, cx).map(|keybind| {
 467                        let edit_entry_label = if is_recent_selected {
 468                            "Edit task"
 469                        } else if !self.matches.is_empty() {
 470                            "Edit template"
 471                        } else {
 472                            "Rerun last task"
 473                        };
 474
 475                        Button::new("edit-current-task", edit_entry_label)
 476                            .label_size(LabelSize::Small)
 477                            .key_binding(keybind)
 478                            .on_click(|_, cx| {
 479                                cx.dispatch_action(picker::UseSelectedQuery.boxed_clone())
 480                            })
 481                    }),
 482                )
 483                .map(|this| {
 484                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 485                    {
 486                        let action = picker::ConfirmInput {
 487                            secondary: current_modifiers.secondary(),
 488                        }
 489                        .boxed_clone();
 490                        this.children(KeyBinding::for_action(&*action, cx).map(|keybind| {
 491                            let spawn_oneshot_label = if current_modifiers.secondary() {
 492                                "Spawn oneshot without history"
 493                            } else {
 494                                "Spawn oneshot"
 495                            };
 496
 497                            Button::new("spawn-onehshot", spawn_oneshot_label)
 498                                .label_size(LabelSize::Small)
 499                                .key_binding(keybind)
 500                                .on_click(move |_, cx| cx.dispatch_action(action.boxed_clone()))
 501                        }))
 502                    } else if current_modifiers.secondary() {
 503                        this.children(KeyBinding::for_action(&menu::SecondaryConfirm, cx).map(
 504                            |keybind| {
 505                                let label = if is_recent_selected {
 506                                    "Rerun without history"
 507                                } else {
 508                                    "Spawn without history"
 509                                };
 510                                Button::new("spawn", label)
 511                                    .label_size(LabelSize::Small)
 512                                    .key_binding(keybind)
 513                                    .on_click(move |_, cx| {
 514                                        cx.dispatch_action(menu::SecondaryConfirm.boxed_clone())
 515                                    })
 516                            },
 517                        ))
 518                    } else {
 519                        this.children(KeyBinding::for_action(&menu::Confirm, cx).map(|keybind| {
 520                            let run_entry_label =
 521                                if is_recent_selected { "Rerun" } else { "Spawn" };
 522
 523                            Button::new("spawn", run_entry_label)
 524                                .label_size(LabelSize::Small)
 525                                .key_binding(keybind)
 526                                .on_click(|_, cx| {
 527                                    cx.dispatch_action(menu::Confirm.boxed_clone());
 528                                })
 529                        }))
 530                    }
 531                })
 532                .into_any_element(),
 533        )
 534    }
 535}
 536
 537#[cfg(test)]
 538mod tests {
 539    use std::{path::PathBuf, sync::Arc};
 540
 541    use editor::Editor;
 542    use gpui::{TestAppContext, VisualTestContext};
 543    use language::{ContextProviderWithTasks, Language, LanguageConfig, LanguageMatcher, Point};
 544    use project::{FakeFs, Project};
 545    use serde_json::json;
 546    use task::TaskTemplates;
 547    use workspace::CloseInactiveTabsAndPanes;
 548
 549    use crate::{modal::Spawn, tests::init_test};
 550
 551    use super::*;
 552
 553    #[gpui::test]
 554    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 555        init_test(cx);
 556        let fs = FakeFs::new(cx.executor());
 557        fs.insert_tree(
 558            "/dir",
 559            json!({
 560                ".zed": {
 561                    "tasks.json": r#"[
 562                        {
 563                            "label": "example task",
 564                            "command": "echo",
 565                            "args": ["4"]
 566                        },
 567                        {
 568                            "label": "another one",
 569                            "command": "echo",
 570                            "args": ["55"]
 571                        },
 572                    ]"#,
 573                },
 574                "a.ts": "a"
 575            }),
 576        )
 577        .await;
 578
 579        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 580        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 581
 582        let tasks_picker = open_spawn_tasks(&workspace, cx);
 583        assert_eq!(
 584            query(&tasks_picker, cx),
 585            "",
 586            "Initial query should be empty"
 587        );
 588        assert_eq!(
 589            task_names(&tasks_picker, cx),
 590            vec!["another one", "example task"],
 591            "Initial tasks should be listed in alphabetical order"
 592        );
 593
 594        let query_str = "tas";
 595        cx.simulate_input(query_str);
 596        assert_eq!(query(&tasks_picker, cx), query_str);
 597        assert_eq!(
 598            task_names(&tasks_picker, cx),
 599            vec!["example task"],
 600            "Only one task should match the query {query_str}"
 601        );
 602
 603        cx.dispatch_action(picker::UseSelectedQuery);
 604        assert_eq!(
 605            query(&tasks_picker, cx),
 606            "echo 4",
 607            "Query should be set to the selected task's command"
 608        );
 609        assert_eq!(
 610            task_names(&tasks_picker, cx),
 611            Vec::<String>::new(),
 612            "No task should be listed"
 613        );
 614        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 615
 616        let tasks_picker = open_spawn_tasks(&workspace, cx);
 617        assert_eq!(
 618            query(&tasks_picker, cx),
 619            "",
 620            "Query should be reset after confirming"
 621        );
 622        assert_eq!(
 623            task_names(&tasks_picker, cx),
 624            vec!["echo 4", "another one", "example task"],
 625            "New oneshot task should be listed first"
 626        );
 627
 628        let query_str = "echo 4";
 629        cx.simulate_input(query_str);
 630        assert_eq!(query(&tasks_picker, cx), query_str);
 631        assert_eq!(
 632            task_names(&tasks_picker, cx),
 633            vec!["echo 4"],
 634            "New oneshot should match custom command query"
 635        );
 636
 637        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 638        let tasks_picker = open_spawn_tasks(&workspace, cx);
 639        assert_eq!(
 640            query(&tasks_picker, cx),
 641            "",
 642            "Query should be reset after confirming"
 643        );
 644        assert_eq!(
 645            task_names(&tasks_picker, cx),
 646            vec![query_str, "another one", "example task"],
 647            "Last recently used one show task should be listed first"
 648        );
 649
 650        cx.dispatch_action(picker::UseSelectedQuery);
 651        assert_eq!(
 652            query(&tasks_picker, cx),
 653            query_str,
 654            "Query should be set to the custom task's name"
 655        );
 656        assert_eq!(
 657            task_names(&tasks_picker, cx),
 658            vec![query_str],
 659            "Only custom task should be listed"
 660        );
 661
 662        let query_str = "0";
 663        cx.simulate_input(query_str);
 664        assert_eq!(query(&tasks_picker, cx), "echo 40");
 665        assert_eq!(
 666            task_names(&tasks_picker, cx),
 667            Vec::<String>::new(),
 668            "New oneshot should not match any command query"
 669        );
 670
 671        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 672        let tasks_picker = open_spawn_tasks(&workspace, cx);
 673        assert_eq!(
 674            query(&tasks_picker, cx),
 675            "",
 676            "Query should be reset after confirming"
 677        );
 678        assert_eq!(
 679            task_names(&tasks_picker, cx),
 680            vec!["echo 4", "another one", "example task"],
 681            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 682        );
 683
 684        cx.dispatch_action(Spawn {
 685            task_name: Some("example task".to_string()),
 686        });
 687        let tasks_picker = workspace.update(cx, |workspace, cx| {
 688            workspace
 689                .active_modal::<TasksModal>(cx)
 690                .unwrap()
 691                .read(cx)
 692                .picker
 693                .clone()
 694        });
 695        assert_eq!(
 696            task_names(&tasks_picker, cx),
 697            vec!["echo 4", "another one", "example task"],
 698        );
 699    }
 700
 701    #[gpui::test]
 702    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 703        init_test(cx);
 704        let fs = FakeFs::new(cx.executor());
 705        fs.insert_tree(
 706            "/dir",
 707            json!({
 708                ".zed": {
 709                    "tasks.json": r#"[
 710                        {
 711                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 712                            "command": "echo",
 713                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 714                        },
 715                        {
 716                            "label": "opened now: $ZED_WORKTREE_ROOT",
 717                            "command": "echo",
 718                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 719                        }
 720                    ]"#,
 721                },
 722                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 723                "file_with.odd_extension": "b",
 724            }),
 725        )
 726        .await;
 727
 728        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 729        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 730
 731        let tasks_picker = open_spawn_tasks(&workspace, cx);
 732        assert_eq!(
 733            task_names(&tasks_picker, cx),
 734            Vec::<String>::new(),
 735            "Should list no file or worktree context-dependent when no file is open"
 736        );
 737        tasks_picker.update(cx, |_, cx| {
 738            cx.emit(DismissEvent);
 739        });
 740        drop(tasks_picker);
 741        cx.executor().run_until_parked();
 742
 743        let _ = workspace
 744            .update(cx, |workspace, cx| {
 745                workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
 746            })
 747            .await
 748            .unwrap();
 749        cx.executor().run_until_parked();
 750        let tasks_picker = open_spawn_tasks(&workspace, cx);
 751        assert_eq!(
 752            task_names(&tasks_picker, cx),
 753            vec![
 754                "hello from …th.odd_extension:1:1".to_string(),
 755                "opened now: /dir".to_string()
 756            ],
 757            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 758        );
 759        tasks_picker.update(cx, |_, cx| {
 760            cx.emit(DismissEvent);
 761        });
 762        drop(tasks_picker);
 763        cx.executor().run_until_parked();
 764
 765        let second_item = workspace
 766            .update(cx, |workspace, cx| {
 767                workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
 768            })
 769            .await
 770            .unwrap();
 771
 772        let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
 773        editor.update(cx, |editor, cx| {
 774            editor.change_selections(None, cx, |s| {
 775                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 776            })
 777        });
 778        cx.executor().run_until_parked();
 779        let tasks_picker = open_spawn_tasks(&workspace, cx);
 780        assert_eq!(
 781            task_names(&tasks_picker, cx),
 782            vec![
 783                "hello from …ithout_extension:2:3".to_string(),
 784                "opened now: /dir".to_string()
 785            ],
 786            "Opened buffer should fill the context, labels should be trimmed if long enough"
 787        );
 788        tasks_picker.update(cx, |_, cx| {
 789            cx.emit(DismissEvent);
 790        });
 791        drop(tasks_picker);
 792        cx.executor().run_until_parked();
 793    }
 794
 795    #[gpui::test]
 796    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 797        init_test(cx);
 798        let fs = FakeFs::new(cx.executor());
 799        fs.insert_tree(
 800            "/dir",
 801            json!({
 802                "a1.ts": "// a1",
 803                "a2.ts": "// a2",
 804                "b.rs": "// b",
 805            }),
 806        )
 807        .await;
 808
 809        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 810        project.read_with(cx, |project, _| {
 811            let language_registry = project.languages();
 812            language_registry.add(Arc::new(
 813                Language::new(
 814                    LanguageConfig {
 815                        name: "TypeScript".into(),
 816                        matcher: LanguageMatcher {
 817                            path_suffixes: vec!["ts".to_string()],
 818                            ..LanguageMatcher::default()
 819                        },
 820                        ..LanguageConfig::default()
 821                    },
 822                    None,
 823                )
 824                .with_context_provider(Some(Arc::new(
 825                    ContextProviderWithTasks::new(TaskTemplates(vec![
 826                        TaskTemplate {
 827                            label: "Task without variables".to_string(),
 828                            command: "npm run clean".to_string(),
 829                            ..TaskTemplate::default()
 830                        },
 831                        TaskTemplate {
 832                            label: "TypeScript task from file $ZED_FILE".to_string(),
 833                            command: "npm run build".to_string(),
 834                            ..TaskTemplate::default()
 835                        },
 836                        TaskTemplate {
 837                            label: "Another task from file $ZED_FILE".to_string(),
 838                            command: "npm run lint".to_string(),
 839                            ..TaskTemplate::default()
 840                        },
 841                    ])),
 842                ))),
 843            ));
 844            language_registry.add(Arc::new(
 845                Language::new(
 846                    LanguageConfig {
 847                        name: "Rust".into(),
 848                        matcher: LanguageMatcher {
 849                            path_suffixes: vec!["rs".to_string()],
 850                            ..LanguageMatcher::default()
 851                        },
 852                        ..LanguageConfig::default()
 853                    },
 854                    None,
 855                )
 856                .with_context_provider(Some(Arc::new(
 857                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 858                        label: "Rust task".to_string(),
 859                        command: "cargo check".into(),
 860                        ..TaskTemplate::default()
 861                    }])),
 862                ))),
 863            ));
 864        });
 865        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 866
 867        let _ts_file_1 = workspace
 868            .update(cx, |workspace, cx| {
 869                workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
 870            })
 871            .await
 872            .unwrap();
 873        let tasks_picker = open_spawn_tasks(&workspace, cx);
 874        assert_eq!(
 875            task_names(&tasks_picker, cx),
 876            vec![
 877                "Another task from file /dir/a1.ts",
 878                "TypeScript task from file /dir/a1.ts",
 879                "Task without variables",
 880            ],
 881            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 882        );
 883        emulate_task_schedule(
 884            tasks_picker,
 885            &project,
 886            "TypeScript task from file /dir/a1.ts",
 887            cx,
 888        );
 889
 890        let tasks_picker = open_spawn_tasks(&workspace, cx);
 891        assert_eq!(
 892            task_names(&tasks_picker, cx),
 893            vec!["TypeScript task from file /dir/a1.ts", "TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
 894            "After spawning the task and getting it into the history, it should be up in the sort as recently used"
 895        );
 896        tasks_picker.update(cx, |_, cx| {
 897            cx.emit(DismissEvent);
 898        });
 899        drop(tasks_picker);
 900        cx.executor().run_until_parked();
 901
 902        let _ts_file_2 = workspace
 903            .update(cx, |workspace, cx| {
 904                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 905            })
 906            .await
 907            .unwrap();
 908        let tasks_picker = open_spawn_tasks(&workspace, cx);
 909        assert_eq!(
 910            task_names(&tasks_picker, cx),
 911            vec![
 912                "TypeScript task from file /dir/a1.ts",
 913                "Another task from file /dir/a2.ts",
 914                "TypeScript task from file /dir/a2.ts",
 915                "Task without variables"
 916            ],
 917            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
 918        );
 919        tasks_picker.update(cx, |_, cx| {
 920            cx.emit(DismissEvent);
 921        });
 922        drop(tasks_picker);
 923        cx.executor().run_until_parked();
 924
 925        let _rs_file = workspace
 926            .update(cx, |workspace, cx| {
 927                workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
 928            })
 929            .await
 930            .unwrap();
 931        let tasks_picker = open_spawn_tasks(&workspace, cx);
 932        assert_eq!(
 933            task_names(&tasks_picker, cx),
 934            vec!["Rust task"],
 935            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
 936        );
 937
 938        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
 939        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
 940        let _ts_file_2 = workspace
 941            .update(cx, |workspace, cx| {
 942                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 943            })
 944            .await
 945            .unwrap();
 946        let tasks_picker = open_spawn_tasks(&workspace, cx);
 947        assert_eq!(
 948            task_names(&tasks_picker, cx),
 949            vec![
 950                "TypeScript task from file /dir/a1.ts",
 951                "Another task from file /dir/a2.ts",
 952                "TypeScript task from file /dir/a2.ts",
 953                "Task without variables"
 954            ],
 955            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
 956            same TS spawn history should be restored"
 957        );
 958    }
 959
 960    fn emulate_task_schedule(
 961        tasks_picker: View<Picker<TasksModalDelegate>>,
 962        project: &Model<Project>,
 963        scheduled_task_label: &str,
 964        cx: &mut VisualTestContext,
 965    ) {
 966        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
 967            tasks_picker
 968                .delegate
 969                .candidates
 970                .iter()
 971                .flatten()
 972                .find(|(_, task)| task.resolved_label == scheduled_task_label)
 973                .cloned()
 974                .unwrap()
 975        });
 976        project.update(cx, |project, cx| {
 977            project.task_inventory().update(cx, |inventory, _| {
 978                let (kind, task) = scheduled_task;
 979                inventory.task_scheduled(kind, task);
 980            })
 981        });
 982        tasks_picker.update(cx, |_, cx| {
 983            cx.emit(DismissEvent);
 984        });
 985        drop(tasks_picker);
 986        cx.executor().run_until_parked()
 987    }
 988
 989    fn open_spawn_tasks(
 990        workspace: &View<Workspace>,
 991        cx: &mut VisualTestContext,
 992    ) -> View<Picker<TasksModalDelegate>> {
 993        cx.dispatch_action(Spawn::default());
 994        workspace.update(cx, |workspace, cx| {
 995            workspace
 996                .active_modal::<TasksModal>(cx)
 997                .expect("no task modal after `Spawn` action was dispatched")
 998                .read(cx)
 999                .picker
1000                .clone()
1001        })
1002    }
1003
1004    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1005        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1006    }
1007
1008    fn task_names(
1009        spawn_tasks: &View<Picker<TasksModalDelegate>>,
1010        cx: &mut VisualTestContext,
1011    ) -> Vec<String> {
1012        spawn_tasks.update(cx, |spawn_tasks, _| {
1013            spawn_tasks
1014                .delegate
1015                .matches
1016                .iter()
1017                .map(|hit| hit.string.clone())
1018                .collect::<Vec<_>>()
1019        })
1020    }
1021}