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, Task,
   8    View, ViewContext, VisualContext, WeakView,
   9};
  10use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate};
  11use project::{task_store::TaskStore, TaskSourceKind};
  12use task::{ResolvedTask, TaskContext, TaskId, 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    /// If present, rerun the task with this ID, otherwise rerun the last task.
  59    pub task_id: Option<TaskId>,
  60}
  61
  62impl_actions!(task, [Rerun, Spawn]);
  63
  64/// A modal used to spawn new tasks.
  65pub(crate) struct TasksModalDelegate {
  66    task_store: Model<TaskStore>,
  67    candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
  68    last_used_candidate_index: Option<usize>,
  69    divider_index: Option<usize>,
  70    matches: Vec<StringMatch>,
  71    selected_index: usize,
  72    workspace: WeakView<Workspace>,
  73    prompt: String,
  74    task_context: TaskContext,
  75    placeholder_text: Arc<str>,
  76}
  77
  78impl TasksModalDelegate {
  79    fn new(
  80        task_store: Model<TaskStore>,
  81        task_context: TaskContext,
  82        workspace: WeakView<Workspace>,
  83    ) -> Self {
  84        Self {
  85            task_store,
  86            workspace,
  87            candidates: None,
  88            matches: Vec::new(),
  89            last_used_candidate_index: None,
  90            divider_index: None,
  91            selected_index: 0,
  92            prompt: String::default(),
  93            task_context,
  94            placeholder_text: Arc::from("Find a task, or run a command"),
  95        }
  96    }
  97
  98    fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
  99        if self.prompt.trim().is_empty() {
 100            return None;
 101        }
 102
 103        let source_kind = TaskSourceKind::UserInput;
 104        let id_base = source_kind.to_id_base();
 105        let new_oneshot = TaskTemplate {
 106            label: self.prompt.clone(),
 107            command: self.prompt.clone(),
 108            ..TaskTemplate::default()
 109        };
 110        Some((
 111            source_kind,
 112            new_oneshot.resolve_task(&id_base, &self.task_context)?,
 113        ))
 114    }
 115
 116    fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
 117        let Some(candidates) = self.candidates.as_mut() else {
 118            return;
 119        };
 120        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
 121            return;
 122        };
 123        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
 124        // 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
 125        // the original list without a removed entry.
 126        candidates.remove(ix);
 127        if let Some(inventory) = self.task_store.read(cx).task_inventory().cloned() {
 128            inventory.update(cx, |inventory, _| {
 129                inventory.delete_previously_used(&task.id);
 130            })
 131        };
 132    }
 133}
 134
 135pub(crate) struct TasksModal {
 136    picker: View<Picker<TasksModalDelegate>>,
 137    _subscription: Subscription,
 138}
 139
 140impl TasksModal {
 141    pub(crate) fn new(
 142        task_store: Model<TaskStore>,
 143        task_context: TaskContext,
 144        workspace: WeakView<Workspace>,
 145        cx: &mut ViewContext<Self>,
 146    ) -> Self {
 147        let picker = cx.new_view(|cx| {
 148            Picker::uniform_list(
 149                TasksModalDelegate::new(task_store, task_context, workspace),
 150                cx,
 151            )
 152        });
 153        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
 154            cx.emit(DismissEvent);
 155        });
 156        Self {
 157            picker,
 158            _subscription,
 159        }
 160    }
 161}
 162
 163impl Render for TasksModal {
 164    fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
 165        v_flex()
 166            .key_context("TasksModal")
 167            .w(rems(34.))
 168            .child(self.picker.clone())
 169    }
 170}
 171
 172impl EventEmitter<DismissEvent> for TasksModal {}
 173
 174impl FocusableView for TasksModal {
 175    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
 176        self.picker.read(cx).focus_handle(cx)
 177    }
 178}
 179
 180impl ModalView for TasksModal {}
 181
 182impl PickerDelegate for TasksModalDelegate {
 183    type ListItem = ListItem;
 184
 185    fn match_count(&self) -> usize {
 186        self.matches.len()
 187    }
 188
 189    fn selected_index(&self) -> usize {
 190        self.selected_index
 191    }
 192
 193    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
 194        self.selected_index = ix;
 195    }
 196
 197    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
 198        self.placeholder_text.clone()
 199    }
 200
 201    fn update_matches(
 202        &mut self,
 203        query: String,
 204        cx: &mut ViewContext<picker::Picker<Self>>,
 205    ) -> Task<()> {
 206        cx.spawn(move |picker, mut cx| async move {
 207            let Some(candidates) = picker
 208                .update(&mut cx, |picker, cx| {
 209                    match &mut picker.delegate.candidates {
 210                        Some(candidates) => string_match_candidates(candidates.iter()),
 211                        None => {
 212                            let Ok((worktree, location)) =
 213                                picker.delegate.workspace.update(cx, |workspace, cx| {
 214                                    active_item_selection_properties(workspace, cx)
 215                                })
 216                            else {
 217                                return Vec::new();
 218                            };
 219                            let Some(task_inventory) = picker
 220                                .delegate
 221                                .task_store
 222                                .read(cx)
 223                                .task_inventory()
 224                                .cloned()
 225                            else {
 226                                return Vec::new();
 227                            };
 228
 229                            let (used, current) =
 230                                task_inventory.read(cx).used_and_current_resolved_tasks(
 231                                    worktree,
 232                                    location,
 233                                    &picker.delegate.task_context,
 234                                    cx,
 235                                );
 236                            picker.delegate.last_used_candidate_index = if used.is_empty() {
 237                                None
 238                            } else {
 239                                Some(used.len() - 1)
 240                            };
 241
 242                            let mut new_candidates = used;
 243                            new_candidates.extend(current);
 244                            let match_candidates = string_match_candidates(new_candidates.iter());
 245                            let _ = picker.delegate.candidates.insert(new_candidates);
 246                            match_candidates
 247                        }
 248                    }
 249                })
 250                .ok()
 251            else {
 252                return;
 253            };
 254            let matches = fuzzy::match_strings(
 255                &candidates,
 256                &query,
 257                true,
 258                1000,
 259                &Default::default(),
 260                cx.background_executor().clone(),
 261            )
 262            .await;
 263            picker
 264                .update(&mut cx, |picker, _| {
 265                    let delegate = &mut picker.delegate;
 266                    delegate.matches = matches;
 267                    if let Some(index) = delegate.last_used_candidate_index {
 268                        delegate.matches.sort_by_key(|m| m.candidate_id > index);
 269                    }
 270
 271                    delegate.prompt = query;
 272                    delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
 273                        let index = delegate
 274                            .matches
 275                            .partition_point(|matching_task| matching_task.candidate_id <= index);
 276                        Some(index).and_then(|index| (index != 0).then(|| index - 1))
 277                    });
 278
 279                    if delegate.matches.is_empty() {
 280                        delegate.selected_index = 0;
 281                    } else {
 282                        delegate.selected_index =
 283                            delegate.selected_index.min(delegate.matches.len() - 1);
 284                    }
 285                })
 286                .log_err();
 287        })
 288    }
 289
 290    fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
 291        let current_match_index = self.selected_index();
 292        let task = self
 293            .matches
 294            .get(current_match_index)
 295            .and_then(|current_match| {
 296                let ix = current_match.candidate_id;
 297                self.candidates
 298                    .as_ref()
 299                    .map(|candidates| candidates[ix].clone())
 300            });
 301        let Some((task_source_kind, task)) = task else {
 302            return;
 303        };
 304
 305        self.workspace
 306            .update(cx, |workspace, cx| {
 307                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
 308            })
 309            .ok();
 310        cx.emit(DismissEvent);
 311    }
 312
 313    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
 314        cx.emit(DismissEvent);
 315    }
 316
 317    fn render_match(
 318        &self,
 319        ix: usize,
 320        selected: bool,
 321        cx: &mut ViewContext<picker::Picker<Self>>,
 322    ) -> Option<Self::ListItem> {
 323        let candidates = self.candidates.as_ref()?;
 324        let hit = &self.matches[ix];
 325        let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
 326        let template = resolved_task.original_task();
 327        let display_label = resolved_task.display_label();
 328
 329        let mut tooltip_label_text = if display_label != &template.label {
 330            resolved_task.resolved_label.clone()
 331        } else {
 332            String::new()
 333        };
 334        if let Some(resolved) = resolved_task.resolved.as_ref() {
 335            if resolved.command_label != display_label
 336                && resolved.command_label != resolved_task.resolved_label
 337            {
 338                if !tooltip_label_text.trim().is_empty() {
 339                    tooltip_label_text.push('\n');
 340                }
 341                tooltip_label_text.push_str(&resolved.command_label);
 342            }
 343        }
 344        let tooltip_label = if tooltip_label_text.trim().is_empty() {
 345            None
 346        } else {
 347            Some(Tooltip::text(tooltip_label_text, cx))
 348        };
 349
 350        let highlighted_location = HighlightedText {
 351            text: hit.string.clone(),
 352            highlight_positions: hit.positions.clone(),
 353            char_count: hit.string.chars().count(),
 354            color: Color::Default,
 355        };
 356        let icon = match source_kind {
 357            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
 358            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
 359            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
 360            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
 361                .get_type_icon(&name.to_lowercase())
 362                .map(Icon::from_path),
 363        }
 364        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 365        let history_run_icon = if Some(ix) <= self.divider_index {
 366            Some(
 367                Icon::new(IconName::HistoryRerun)
 368                    .color(Color::Muted)
 369                    .size(IconSize::Small)
 370                    .into_any_element(),
 371            )
 372        } else {
 373            Some(
 374                v_flex()
 375                    .flex_none()
 376                    .size(IconSize::Small.rems())
 377                    .into_any_element(),
 378            )
 379        };
 380
 381        Some(
 382            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 383                .inset(true)
 384                .start_slot::<Icon>(icon)
 385                .end_slot::<AnyElement>(history_run_icon)
 386                .spacing(ListItemSpacing::Sparse)
 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 confirm_completion(
 429        &mut self,
 430        _: String,
 431        _: &mut ViewContext<Picker<Self>>,
 432    ) -> Option<String> {
 433        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 434        let tasks = self.candidates.as_ref()?;
 435        let (_, task) = tasks.get(task_index)?;
 436        Some(task.resolved.as_ref()?.command_label.clone())
 437    }
 438
 439    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
 440        let Some((task_source_kind, task)) = self.spawn_oneshot() else {
 441            return;
 442        };
 443        self.workspace
 444            .update(cx, |workspace, cx| {
 445                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
 446            })
 447            .ok();
 448        cx.emit(DismissEvent);
 449    }
 450
 451    fn separators_after_indices(&self) -> Vec<usize> {
 452        if let Some(i) = self.divider_index {
 453            vec![i]
 454        } else {
 455            Vec::new()
 456        }
 457    }
 458    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<gpui::AnyElement> {
 459        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 460        let current_modifiers = cx.modifiers();
 461        let left_button = if self
 462            .task_store
 463            .read(cx)
 464            .task_inventory()?
 465            .read(cx)
 466            .last_scheduled_task(None)
 467            .is_some()
 468        {
 469            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 470        } else {
 471            None
 472        };
 473        Some(
 474            h_flex()
 475                .w_full()
 476                .h_8()
 477                .p_2()
 478                .justify_between()
 479                .rounded_b_md()
 480                .bg(cx.theme().colors().ghost_element_selected)
 481                .border_t_1()
 482                .border_color(cx.theme().colors().border_variant)
 483                .child(
 484                    left_button
 485                        .map(|(label, action)| {
 486                            let keybind = KeyBinding::for_action(&*action, cx);
 487
 488                            Button::new("edit-current-task", label)
 489                                .label_size(LabelSize::Small)
 490                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 491                                .on_click(move |_, cx| {
 492                                    cx.dispatch_action(action.boxed_clone());
 493                                })
 494                                .into_any_element()
 495                        })
 496                        .unwrap_or_else(|| h_flex().into_any_element()),
 497                )
 498                .map(|this| {
 499                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 500                    {
 501                        let action = picker::ConfirmInput {
 502                            secondary: current_modifiers.secondary(),
 503                        }
 504                        .boxed_clone();
 505                        this.children(KeyBinding::for_action(&*action, cx).map(|keybind| {
 506                            let spawn_oneshot_label = if current_modifiers.secondary() {
 507                                "Spawn Oneshot Without History"
 508                            } else {
 509                                "Spawn Oneshot"
 510                            };
 511
 512                            Button::new("spawn-onehshot", spawn_oneshot_label)
 513                                .label_size(LabelSize::Small)
 514                                .key_binding(keybind)
 515                                .on_click(move |_, cx| cx.dispatch_action(action.boxed_clone()))
 516                        }))
 517                    } else if current_modifiers.secondary() {
 518                        this.children(KeyBinding::for_action(&menu::SecondaryConfirm, cx).map(
 519                            |keybind| {
 520                                let label = if is_recent_selected {
 521                                    "Rerun Without History"
 522                                } else {
 523                                    "Spawn Without History"
 524                                };
 525                                Button::new("spawn", label)
 526                                    .label_size(LabelSize::Small)
 527                                    .key_binding(keybind)
 528                                    .on_click(move |_, cx| {
 529                                        cx.dispatch_action(menu::SecondaryConfirm.boxed_clone())
 530                                    })
 531                            },
 532                        ))
 533                    } else {
 534                        this.children(KeyBinding::for_action(&menu::Confirm, cx).map(|keybind| {
 535                            let run_entry_label =
 536                                if is_recent_selected { "Rerun" } else { "Spawn" };
 537
 538                            Button::new("spawn", run_entry_label)
 539                                .label_size(LabelSize::Small)
 540                                .key_binding(keybind)
 541                                .on_click(|_, cx| {
 542                                    cx.dispatch_action(menu::Confirm.boxed_clone());
 543                                })
 544                        }))
 545                    }
 546                })
 547                .into_any_element(),
 548        )
 549    }
 550}
 551
 552fn string_match_candidates<'a>(
 553    candidates: impl Iterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 554) -> Vec<StringMatchCandidate> {
 555    candidates
 556        .enumerate()
 557        .map(|(index, (_, candidate))| StringMatchCandidate {
 558            id: index,
 559            char_bag: candidate.resolved_label.chars().collect(),
 560            string: candidate.display_label().to_owned(),
 561        })
 562        .collect()
 563}
 564
 565#[cfg(test)]
 566mod tests {
 567    use std::{path::PathBuf, sync::Arc};
 568
 569    use editor::Editor;
 570    use gpui::{TestAppContext, VisualTestContext};
 571    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 572    use project::{ContextProviderWithTasks, FakeFs, Project};
 573    use serde_json::json;
 574    use task::TaskTemplates;
 575    use workspace::CloseInactiveTabsAndPanes;
 576
 577    use crate::{modal::Spawn, tests::init_test};
 578
 579    use super::*;
 580
 581    #[gpui::test]
 582    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 583        init_test(cx);
 584        let fs = FakeFs::new(cx.executor());
 585        fs.insert_tree(
 586            "/dir",
 587            json!({
 588                ".zed": {
 589                    "tasks.json": r#"[
 590                        {
 591                            "label": "example task",
 592                            "command": "echo",
 593                            "args": ["4"]
 594                        },
 595                        {
 596                            "label": "another one",
 597                            "command": "echo",
 598                            "args": ["55"]
 599                        },
 600                    ]"#,
 601                },
 602                "a.ts": "a"
 603            }),
 604        )
 605        .await;
 606
 607        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 608        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 609
 610        let tasks_picker = open_spawn_tasks(&workspace, cx);
 611        assert_eq!(
 612            query(&tasks_picker, cx),
 613            "",
 614            "Initial query should be empty"
 615        );
 616        assert_eq!(
 617            task_names(&tasks_picker, cx),
 618            Vec::<String>::new(),
 619            "With no global tasks and no open item, no tasks should be listed"
 620        );
 621        drop(tasks_picker);
 622
 623        let _ = workspace
 624            .update(cx, |workspace, cx| {
 625                workspace.open_abs_path(PathBuf::from("/dir/a.ts"), true, cx)
 626            })
 627            .await
 628            .unwrap();
 629        let tasks_picker = open_spawn_tasks(&workspace, cx);
 630        assert_eq!(
 631            task_names(&tasks_picker, cx),
 632            vec!["another one", "example task"],
 633            "Initial tasks should be listed in alphabetical order"
 634        );
 635
 636        let query_str = "tas";
 637        cx.simulate_input(query_str);
 638        assert_eq!(query(&tasks_picker, cx), query_str);
 639        assert_eq!(
 640            task_names(&tasks_picker, cx),
 641            vec!["example task"],
 642            "Only one task should match the query {query_str}"
 643        );
 644
 645        cx.dispatch_action(picker::ConfirmCompletion);
 646        assert_eq!(
 647            query(&tasks_picker, cx),
 648            "echo 4",
 649            "Query should be set to the selected task's command"
 650        );
 651        assert_eq!(
 652            task_names(&tasks_picker, cx),
 653            Vec::<String>::new(),
 654            "No task should be listed"
 655        );
 656        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 657
 658        let tasks_picker = open_spawn_tasks(&workspace, cx);
 659        assert_eq!(
 660            query(&tasks_picker, cx),
 661            "",
 662            "Query should be reset after confirming"
 663        );
 664        assert_eq!(
 665            task_names(&tasks_picker, cx),
 666            vec!["echo 4", "another one", "example task"],
 667            "New oneshot task should be listed first"
 668        );
 669
 670        let query_str = "echo 4";
 671        cx.simulate_input(query_str);
 672        assert_eq!(query(&tasks_picker, cx), query_str);
 673        assert_eq!(
 674            task_names(&tasks_picker, cx),
 675            vec!["echo 4"],
 676            "New oneshot should match custom command query"
 677        );
 678
 679        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 680        let tasks_picker = open_spawn_tasks(&workspace, cx);
 681        assert_eq!(
 682            query(&tasks_picker, cx),
 683            "",
 684            "Query should be reset after confirming"
 685        );
 686        assert_eq!(
 687            task_names(&tasks_picker, cx),
 688            vec![query_str, "another one", "example task"],
 689            "Last recently used one show task should be listed first"
 690        );
 691
 692        cx.dispatch_action(picker::ConfirmCompletion);
 693        assert_eq!(
 694            query(&tasks_picker, cx),
 695            query_str,
 696            "Query should be set to the custom task's name"
 697        );
 698        assert_eq!(
 699            task_names(&tasks_picker, cx),
 700            vec![query_str],
 701            "Only custom task should be listed"
 702        );
 703
 704        let query_str = "0";
 705        cx.simulate_input(query_str);
 706        assert_eq!(query(&tasks_picker, cx), "echo 40");
 707        assert_eq!(
 708            task_names(&tasks_picker, cx),
 709            Vec::<String>::new(),
 710            "New oneshot should not match any command query"
 711        );
 712
 713        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 714        let tasks_picker = open_spawn_tasks(&workspace, cx);
 715        assert_eq!(
 716            query(&tasks_picker, cx),
 717            "",
 718            "Query should be reset after confirming"
 719        );
 720        assert_eq!(
 721            task_names(&tasks_picker, cx),
 722            vec!["echo 4", "another one", "example task"],
 723            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 724        );
 725
 726        cx.dispatch_action(Spawn {
 727            task_name: Some("example task".to_string()),
 728        });
 729        let tasks_picker = workspace.update(cx, |workspace, cx| {
 730            workspace
 731                .active_modal::<TasksModal>(cx)
 732                .unwrap()
 733                .read(cx)
 734                .picker
 735                .clone()
 736        });
 737        assert_eq!(
 738            task_names(&tasks_picker, cx),
 739            vec!["echo 4", "another one", "example task"],
 740        );
 741    }
 742
 743    #[gpui::test]
 744    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 745        init_test(cx);
 746        let fs = FakeFs::new(cx.executor());
 747        fs.insert_tree(
 748            "/dir",
 749            json!({
 750                ".zed": {
 751                    "tasks.json": r#"[
 752                        {
 753                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 754                            "command": "echo",
 755                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 756                        },
 757                        {
 758                            "label": "opened now: $ZED_WORKTREE_ROOT",
 759                            "command": "echo",
 760                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 761                        }
 762                    ]"#,
 763                },
 764                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 765                "file_with.odd_extension": "b",
 766            }),
 767        )
 768        .await;
 769
 770        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 771        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 772
 773        let tasks_picker = open_spawn_tasks(&workspace, cx);
 774        assert_eq!(
 775            task_names(&tasks_picker, cx),
 776            Vec::<String>::new(),
 777            "Should list no file or worktree context-dependent when no file is open"
 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 _ = workspace
 786            .update(cx, |workspace, cx| {
 787                workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
 788            })
 789            .await
 790            .unwrap();
 791        cx.executor().run_until_parked();
 792        let tasks_picker = open_spawn_tasks(&workspace, cx);
 793        assert_eq!(
 794            task_names(&tasks_picker, cx),
 795            vec![
 796                "hello from …th.odd_extension:1:1".to_string(),
 797                "opened now: /dir".to_string()
 798            ],
 799            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 800        );
 801        tasks_picker.update(cx, |_, cx| {
 802            cx.emit(DismissEvent);
 803        });
 804        drop(tasks_picker);
 805        cx.executor().run_until_parked();
 806
 807        let second_item = workspace
 808            .update(cx, |workspace, cx| {
 809                workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
 810            })
 811            .await
 812            .unwrap();
 813
 814        let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
 815        editor.update(cx, |editor, cx| {
 816            editor.change_selections(None, cx, |s| {
 817                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 818            })
 819        });
 820        cx.executor().run_until_parked();
 821        let tasks_picker = open_spawn_tasks(&workspace, cx);
 822        assert_eq!(
 823            task_names(&tasks_picker, cx),
 824            vec![
 825                "hello from …ithout_extension:2:3".to_string(),
 826                "opened now: /dir".to_string()
 827            ],
 828            "Opened buffer should fill the context, labels should be trimmed if long enough"
 829        );
 830        tasks_picker.update(cx, |_, cx| {
 831            cx.emit(DismissEvent);
 832        });
 833        drop(tasks_picker);
 834        cx.executor().run_until_parked();
 835    }
 836
 837    #[gpui::test]
 838    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 839        init_test(cx);
 840        let fs = FakeFs::new(cx.executor());
 841        fs.insert_tree(
 842            "/dir",
 843            json!({
 844                "a1.ts": "// a1",
 845                "a2.ts": "// a2",
 846                "b.rs": "// b",
 847            }),
 848        )
 849        .await;
 850
 851        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 852        project.read_with(cx, |project, _| {
 853            let language_registry = project.languages();
 854            language_registry.add(Arc::new(
 855                Language::new(
 856                    LanguageConfig {
 857                        name: "TypeScript".into(),
 858                        matcher: LanguageMatcher {
 859                            path_suffixes: vec!["ts".to_string()],
 860                            ..LanguageMatcher::default()
 861                        },
 862                        ..LanguageConfig::default()
 863                    },
 864                    None,
 865                )
 866                .with_context_provider(Some(Arc::new(
 867                    ContextProviderWithTasks::new(TaskTemplates(vec![
 868                        TaskTemplate {
 869                            label: "Task without variables".to_string(),
 870                            command: "npm run clean".to_string(),
 871                            ..TaskTemplate::default()
 872                        },
 873                        TaskTemplate {
 874                            label: "TypeScript task from file $ZED_FILE".to_string(),
 875                            command: "npm run build".to_string(),
 876                            ..TaskTemplate::default()
 877                        },
 878                        TaskTemplate {
 879                            label: "Another task from file $ZED_FILE".to_string(),
 880                            command: "npm run lint".to_string(),
 881                            ..TaskTemplate::default()
 882                        },
 883                    ])),
 884                ))),
 885            ));
 886            language_registry.add(Arc::new(
 887                Language::new(
 888                    LanguageConfig {
 889                        name: "Rust".into(),
 890                        matcher: LanguageMatcher {
 891                            path_suffixes: vec!["rs".to_string()],
 892                            ..LanguageMatcher::default()
 893                        },
 894                        ..LanguageConfig::default()
 895                    },
 896                    None,
 897                )
 898                .with_context_provider(Some(Arc::new(
 899                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 900                        label: "Rust task".to_string(),
 901                        command: "cargo check".into(),
 902                        ..TaskTemplate::default()
 903                    }])),
 904                ))),
 905            ));
 906        });
 907        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 908
 909        let _ts_file_1 = workspace
 910            .update(cx, |workspace, cx| {
 911                workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
 912            })
 913            .await
 914            .unwrap();
 915        let tasks_picker = open_spawn_tasks(&workspace, cx);
 916        assert_eq!(
 917            task_names(&tasks_picker, cx),
 918            vec![
 919                "Another task from file /dir/a1.ts",
 920                "TypeScript task from file /dir/a1.ts",
 921                "Task without variables",
 922            ],
 923            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 924        );
 925        emulate_task_schedule(
 926            tasks_picker,
 927            &project,
 928            "TypeScript task from file /dir/a1.ts",
 929            cx,
 930        );
 931
 932        let tasks_picker = open_spawn_tasks(&workspace, cx);
 933        assert_eq!(
 934            task_names(&tasks_picker, cx),
 935            vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
 936            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
 937            Tasks with the same labels and context are deduplicated."
 938        );
 939        tasks_picker.update(cx, |_, cx| {
 940            cx.emit(DismissEvent);
 941        });
 942        drop(tasks_picker);
 943        cx.executor().run_until_parked();
 944
 945        let _ts_file_2 = workspace
 946            .update(cx, |workspace, cx| {
 947                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 948            })
 949            .await
 950            .unwrap();
 951        let tasks_picker = open_spawn_tasks(&workspace, cx);
 952        assert_eq!(
 953            task_names(&tasks_picker, cx),
 954            vec![
 955                "TypeScript task from file /dir/a1.ts",
 956                "Another task from file /dir/a2.ts",
 957                "TypeScript task from file /dir/a2.ts",
 958                "Task without variables"
 959            ],
 960            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
 961        );
 962        tasks_picker.update(cx, |_, cx| {
 963            cx.emit(DismissEvent);
 964        });
 965        drop(tasks_picker);
 966        cx.executor().run_until_parked();
 967
 968        let _rs_file = workspace
 969            .update(cx, |workspace, cx| {
 970                workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
 971            })
 972            .await
 973            .unwrap();
 974        let tasks_picker = open_spawn_tasks(&workspace, cx);
 975        assert_eq!(
 976            task_names(&tasks_picker, cx),
 977            vec!["Rust task"],
 978            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
 979        );
 980
 981        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
 982        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
 983        let _ts_file_2 = workspace
 984            .update(cx, |workspace, cx| {
 985                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 986            })
 987            .await
 988            .unwrap();
 989        let tasks_picker = open_spawn_tasks(&workspace, cx);
 990        assert_eq!(
 991            task_names(&tasks_picker, cx),
 992            vec![
 993                "TypeScript task from file /dir/a1.ts",
 994                "Another task from file /dir/a2.ts",
 995                "TypeScript task from file /dir/a2.ts",
 996                "Task without variables"
 997            ],
 998            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
 999            same TS spawn history should be restored"
1000        );
1001    }
1002
1003    fn emulate_task_schedule(
1004        tasks_picker: View<Picker<TasksModalDelegate>>,
1005        project: &Model<Project>,
1006        scheduled_task_label: &str,
1007        cx: &mut VisualTestContext,
1008    ) {
1009        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1010            tasks_picker
1011                .delegate
1012                .candidates
1013                .iter()
1014                .flatten()
1015                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1016                .cloned()
1017                .unwrap()
1018        });
1019        project.update(cx, |project, cx| {
1020            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1021                task_inventory.update(cx, |inventory, _| {
1022                    let (kind, task) = scheduled_task;
1023                    inventory.task_scheduled(kind, task);
1024                });
1025            }
1026        });
1027        tasks_picker.update(cx, |_, cx| {
1028            cx.emit(DismissEvent);
1029        });
1030        drop(tasks_picker);
1031        cx.executor().run_until_parked()
1032    }
1033
1034    fn open_spawn_tasks(
1035        workspace: &View<Workspace>,
1036        cx: &mut VisualTestContext,
1037    ) -> View<Picker<TasksModalDelegate>> {
1038        cx.dispatch_action(Spawn::default());
1039        workspace.update(cx, |workspace, cx| {
1040            workspace
1041                .active_modal::<TasksModal>(cx)
1042                .expect("no task modal after `Spawn` action was dispatched")
1043                .read(cx)
1044                .picker
1045                .clone()
1046        })
1047    }
1048
1049    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1050        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1051    }
1052
1053    fn task_names(
1054        spawn_tasks: &View<Picker<TasksModalDelegate>>,
1055        cx: &mut VisualTestContext,
1056    ) -> Vec<String> {
1057        spawn_tasks.update(cx, |spawn_tasks, _| {
1058            spawn_tasks
1059                .delegate
1060                .matches
1061                .iter()
1062                .map(|hit| hit.string.clone())
1063                .collect::<Vec<_>>()
1064        })
1065    }
1066}