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(&self, _: String) -> 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        let left_button = if self
 458            .task_store
 459            .read(cx)
 460            .task_inventory()?
 461            .read(cx)
 462            .last_scheduled_task(None)
 463            .is_some()
 464        {
 465            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 466        } else {
 467            None
 468        };
 469        Some(
 470            h_flex()
 471                .w_full()
 472                .h_8()
 473                .p_2()
 474                .justify_between()
 475                .rounded_b_md()
 476                .bg(cx.theme().colors().ghost_element_selected)
 477                .border_t_1()
 478                .border_color(cx.theme().colors().border_variant)
 479                .child(
 480                    left_button
 481                        .map(|(label, action)| {
 482                            let keybind = KeyBinding::for_action(&*action, cx);
 483
 484                            Button::new("edit-current-task", label)
 485                                .label_size(LabelSize::Small)
 486                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 487                                .on_click(move |_, cx| {
 488                                    cx.dispatch_action(action.boxed_clone());
 489                                })
 490                                .into_any_element()
 491                        })
 492                        .unwrap_or_else(|| h_flex().into_any_element()),
 493                )
 494                .map(|this| {
 495                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 496                    {
 497                        let action = picker::ConfirmInput {
 498                            secondary: current_modifiers.secondary(),
 499                        }
 500                        .boxed_clone();
 501                        this.children(KeyBinding::for_action(&*action, cx).map(|keybind| {
 502                            let spawn_oneshot_label = if current_modifiers.secondary() {
 503                                "Spawn Oneshot Without History"
 504                            } else {
 505                                "Spawn Oneshot"
 506                            };
 507
 508                            Button::new("spawn-onehshot", spawn_oneshot_label)
 509                                .label_size(LabelSize::Small)
 510                                .key_binding(keybind)
 511                                .on_click(move |_, cx| cx.dispatch_action(action.boxed_clone()))
 512                        }))
 513                    } else if current_modifiers.secondary() {
 514                        this.children(KeyBinding::for_action(&menu::SecondaryConfirm, cx).map(
 515                            |keybind| {
 516                                let label = if is_recent_selected {
 517                                    "Rerun Without History"
 518                                } else {
 519                                    "Spawn Without History"
 520                                };
 521                                Button::new("spawn", label)
 522                                    .label_size(LabelSize::Small)
 523                                    .key_binding(keybind)
 524                                    .on_click(move |_, cx| {
 525                                        cx.dispatch_action(menu::SecondaryConfirm.boxed_clone())
 526                                    })
 527                            },
 528                        ))
 529                    } else {
 530                        this.children(KeyBinding::for_action(&menu::Confirm, cx).map(|keybind| {
 531                            let run_entry_label =
 532                                if is_recent_selected { "Rerun" } else { "Spawn" };
 533
 534                            Button::new("spawn", run_entry_label)
 535                                .label_size(LabelSize::Small)
 536                                .key_binding(keybind)
 537                                .on_click(|_, cx| {
 538                                    cx.dispatch_action(menu::Confirm.boxed_clone());
 539                                })
 540                        }))
 541                    }
 542                })
 543                .into_any_element(),
 544        )
 545    }
 546}
 547
 548fn string_match_candidates<'a>(
 549    candidates: impl Iterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 550) -> Vec<StringMatchCandidate> {
 551    candidates
 552        .enumerate()
 553        .map(|(index, (_, candidate))| StringMatchCandidate {
 554            id: index,
 555            char_bag: candidate.resolved_label.chars().collect(),
 556            string: candidate.display_label().to_owned(),
 557        })
 558        .collect()
 559}
 560
 561#[cfg(test)]
 562mod tests {
 563    use std::{path::PathBuf, sync::Arc};
 564
 565    use editor::Editor;
 566    use gpui::{TestAppContext, VisualTestContext};
 567    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 568    use project::{ContextProviderWithTasks, FakeFs, Project};
 569    use serde_json::json;
 570    use task::TaskTemplates;
 571    use workspace::CloseInactiveTabsAndPanes;
 572
 573    use crate::{modal::Spawn, tests::init_test};
 574
 575    use super::*;
 576
 577    #[gpui::test]
 578    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 579        init_test(cx);
 580        let fs = FakeFs::new(cx.executor());
 581        fs.insert_tree(
 582            "/dir",
 583            json!({
 584                ".zed": {
 585                    "tasks.json": r#"[
 586                        {
 587                            "label": "example task",
 588                            "command": "echo",
 589                            "args": ["4"]
 590                        },
 591                        {
 592                            "label": "another one",
 593                            "command": "echo",
 594                            "args": ["55"]
 595                        },
 596                    ]"#,
 597                },
 598                "a.ts": "a"
 599            }),
 600        )
 601        .await;
 602
 603        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 604        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 605
 606        let tasks_picker = open_spawn_tasks(&workspace, cx);
 607        assert_eq!(
 608            query(&tasks_picker, cx),
 609            "",
 610            "Initial query should be empty"
 611        );
 612        assert_eq!(
 613            task_names(&tasks_picker, cx),
 614            Vec::<String>::new(),
 615            "With no global tasks and no open item, no tasks should be listed"
 616        );
 617        drop(tasks_picker);
 618
 619        let _ = workspace
 620            .update(cx, |workspace, cx| {
 621                workspace.open_abs_path(PathBuf::from("/dir/a.ts"), true, cx)
 622            })
 623            .await
 624            .unwrap();
 625        let tasks_picker = open_spawn_tasks(&workspace, cx);
 626        assert_eq!(
 627            task_names(&tasks_picker, cx),
 628            vec!["another one", "example task"],
 629            "Initial tasks should be listed in alphabetical order"
 630        );
 631
 632        let query_str = "tas";
 633        cx.simulate_input(query_str);
 634        assert_eq!(query(&tasks_picker, cx), query_str);
 635        assert_eq!(
 636            task_names(&tasks_picker, cx),
 637            vec!["example task"],
 638            "Only one task should match the query {query_str}"
 639        );
 640
 641        cx.dispatch_action(picker::ConfirmCompletion);
 642        assert_eq!(
 643            query(&tasks_picker, cx),
 644            "echo 4",
 645            "Query should be set to the selected task's command"
 646        );
 647        assert_eq!(
 648            task_names(&tasks_picker, cx),
 649            Vec::<String>::new(),
 650            "No task should be listed"
 651        );
 652        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 653
 654        let tasks_picker = open_spawn_tasks(&workspace, cx);
 655        assert_eq!(
 656            query(&tasks_picker, cx),
 657            "",
 658            "Query should be reset after confirming"
 659        );
 660        assert_eq!(
 661            task_names(&tasks_picker, cx),
 662            vec!["echo 4", "another one", "example task"],
 663            "New oneshot task should be listed first"
 664        );
 665
 666        let query_str = "echo 4";
 667        cx.simulate_input(query_str);
 668        assert_eq!(query(&tasks_picker, cx), query_str);
 669        assert_eq!(
 670            task_names(&tasks_picker, cx),
 671            vec!["echo 4"],
 672            "New oneshot should match custom command query"
 673        );
 674
 675        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 676        let tasks_picker = open_spawn_tasks(&workspace, cx);
 677        assert_eq!(
 678            query(&tasks_picker, cx),
 679            "",
 680            "Query should be reset after confirming"
 681        );
 682        assert_eq!(
 683            task_names(&tasks_picker, cx),
 684            vec![query_str, "another one", "example task"],
 685            "Last recently used one show task should be listed first"
 686        );
 687
 688        cx.dispatch_action(picker::ConfirmCompletion);
 689        assert_eq!(
 690            query(&tasks_picker, cx),
 691            query_str,
 692            "Query should be set to the custom task's name"
 693        );
 694        assert_eq!(
 695            task_names(&tasks_picker, cx),
 696            vec![query_str],
 697            "Only custom task should be listed"
 698        );
 699
 700        let query_str = "0";
 701        cx.simulate_input(query_str);
 702        assert_eq!(query(&tasks_picker, cx), "echo 40");
 703        assert_eq!(
 704            task_names(&tasks_picker, cx),
 705            Vec::<String>::new(),
 706            "New oneshot should not match any command query"
 707        );
 708
 709        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 710        let tasks_picker = open_spawn_tasks(&workspace, cx);
 711        assert_eq!(
 712            query(&tasks_picker, cx),
 713            "",
 714            "Query should be reset after confirming"
 715        );
 716        assert_eq!(
 717            task_names(&tasks_picker, cx),
 718            vec!["echo 4", "another one", "example task"],
 719            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 720        );
 721
 722        cx.dispatch_action(Spawn {
 723            task_name: Some("example task".to_string()),
 724        });
 725        let tasks_picker = workspace.update(cx, |workspace, cx| {
 726            workspace
 727                .active_modal::<TasksModal>(cx)
 728                .unwrap()
 729                .read(cx)
 730                .picker
 731                .clone()
 732        });
 733        assert_eq!(
 734            task_names(&tasks_picker, cx),
 735            vec!["echo 4", "another one", "example task"],
 736        );
 737    }
 738
 739    #[gpui::test]
 740    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 741        init_test(cx);
 742        let fs = FakeFs::new(cx.executor());
 743        fs.insert_tree(
 744            "/dir",
 745            json!({
 746                ".zed": {
 747                    "tasks.json": r#"[
 748                        {
 749                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 750                            "command": "echo",
 751                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 752                        },
 753                        {
 754                            "label": "opened now: $ZED_WORKTREE_ROOT",
 755                            "command": "echo",
 756                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 757                        }
 758                    ]"#,
 759                },
 760                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 761                "file_with.odd_extension": "b",
 762            }),
 763        )
 764        .await;
 765
 766        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 767        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 768
 769        let tasks_picker = open_spawn_tasks(&workspace, cx);
 770        assert_eq!(
 771            task_names(&tasks_picker, cx),
 772            Vec::<String>::new(),
 773            "Should list no file or worktree context-dependent when no file is open"
 774        );
 775        tasks_picker.update(cx, |_, cx| {
 776            cx.emit(DismissEvent);
 777        });
 778        drop(tasks_picker);
 779        cx.executor().run_until_parked();
 780
 781        let _ = workspace
 782            .update(cx, |workspace, cx| {
 783                workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
 784            })
 785            .await
 786            .unwrap();
 787        cx.executor().run_until_parked();
 788        let tasks_picker = open_spawn_tasks(&workspace, cx);
 789        assert_eq!(
 790            task_names(&tasks_picker, cx),
 791            vec![
 792                "hello from …th.odd_extension:1:1".to_string(),
 793                "opened now: /dir".to_string()
 794            ],
 795            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 796        );
 797        tasks_picker.update(cx, |_, cx| {
 798            cx.emit(DismissEvent);
 799        });
 800        drop(tasks_picker);
 801        cx.executor().run_until_parked();
 802
 803        let second_item = workspace
 804            .update(cx, |workspace, cx| {
 805                workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
 806            })
 807            .await
 808            .unwrap();
 809
 810        let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
 811        editor.update(cx, |editor, cx| {
 812            editor.change_selections(None, cx, |s| {
 813                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 814            })
 815        });
 816        cx.executor().run_until_parked();
 817        let tasks_picker = open_spawn_tasks(&workspace, cx);
 818        assert_eq!(
 819            task_names(&tasks_picker, cx),
 820            vec![
 821                "hello from …ithout_extension:2:3".to_string(),
 822                "opened now: /dir".to_string()
 823            ],
 824            "Opened buffer should fill the context, labels should be trimmed if long enough"
 825        );
 826        tasks_picker.update(cx, |_, cx| {
 827            cx.emit(DismissEvent);
 828        });
 829        drop(tasks_picker);
 830        cx.executor().run_until_parked();
 831    }
 832
 833    #[gpui::test]
 834    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 835        init_test(cx);
 836        let fs = FakeFs::new(cx.executor());
 837        fs.insert_tree(
 838            "/dir",
 839            json!({
 840                "a1.ts": "// a1",
 841                "a2.ts": "// a2",
 842                "b.rs": "// b",
 843            }),
 844        )
 845        .await;
 846
 847        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 848        project.read_with(cx, |project, _| {
 849            let language_registry = project.languages();
 850            language_registry.add(Arc::new(
 851                Language::new(
 852                    LanguageConfig {
 853                        name: "TypeScript".into(),
 854                        matcher: LanguageMatcher {
 855                            path_suffixes: vec!["ts".to_string()],
 856                            ..LanguageMatcher::default()
 857                        },
 858                        ..LanguageConfig::default()
 859                    },
 860                    None,
 861                )
 862                .with_context_provider(Some(Arc::new(
 863                    ContextProviderWithTasks::new(TaskTemplates(vec![
 864                        TaskTemplate {
 865                            label: "Task without variables".to_string(),
 866                            command: "npm run clean".to_string(),
 867                            ..TaskTemplate::default()
 868                        },
 869                        TaskTemplate {
 870                            label: "TypeScript task from file $ZED_FILE".to_string(),
 871                            command: "npm run build".to_string(),
 872                            ..TaskTemplate::default()
 873                        },
 874                        TaskTemplate {
 875                            label: "Another task from file $ZED_FILE".to_string(),
 876                            command: "npm run lint".to_string(),
 877                            ..TaskTemplate::default()
 878                        },
 879                    ])),
 880                ))),
 881            ));
 882            language_registry.add(Arc::new(
 883                Language::new(
 884                    LanguageConfig {
 885                        name: "Rust".into(),
 886                        matcher: LanguageMatcher {
 887                            path_suffixes: vec!["rs".to_string()],
 888                            ..LanguageMatcher::default()
 889                        },
 890                        ..LanguageConfig::default()
 891                    },
 892                    None,
 893                )
 894                .with_context_provider(Some(Arc::new(
 895                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 896                        label: "Rust task".to_string(),
 897                        command: "cargo check".into(),
 898                        ..TaskTemplate::default()
 899                    }])),
 900                ))),
 901            ));
 902        });
 903        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 904
 905        let _ts_file_1 = workspace
 906            .update(cx, |workspace, cx| {
 907                workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
 908            })
 909            .await
 910            .unwrap();
 911        let tasks_picker = open_spawn_tasks(&workspace, cx);
 912        assert_eq!(
 913            task_names(&tasks_picker, cx),
 914            vec![
 915                "Another task from file /dir/a1.ts",
 916                "TypeScript task from file /dir/a1.ts",
 917                "Task without variables",
 918            ],
 919            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 920        );
 921        emulate_task_schedule(
 922            tasks_picker,
 923            &project,
 924            "TypeScript task from file /dir/a1.ts",
 925            cx,
 926        );
 927
 928        let tasks_picker = open_spawn_tasks(&workspace, cx);
 929        assert_eq!(
 930            task_names(&tasks_picker, cx),
 931            vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
 932            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
 933            Tasks with the same labels and context are deduplicated."
 934        );
 935        tasks_picker.update(cx, |_, cx| {
 936            cx.emit(DismissEvent);
 937        });
 938        drop(tasks_picker);
 939        cx.executor().run_until_parked();
 940
 941        let _ts_file_2 = workspace
 942            .update(cx, |workspace, cx| {
 943                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 944            })
 945            .await
 946            .unwrap();
 947        let tasks_picker = open_spawn_tasks(&workspace, cx);
 948        assert_eq!(
 949            task_names(&tasks_picker, cx),
 950            vec![
 951                "TypeScript task from file /dir/a1.ts",
 952                "Another task from file /dir/a2.ts",
 953                "TypeScript task from file /dir/a2.ts",
 954                "Task without variables"
 955            ],
 956            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
 957        );
 958        tasks_picker.update(cx, |_, cx| {
 959            cx.emit(DismissEvent);
 960        });
 961        drop(tasks_picker);
 962        cx.executor().run_until_parked();
 963
 964        let _rs_file = workspace
 965            .update(cx, |workspace, cx| {
 966                workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
 967            })
 968            .await
 969            .unwrap();
 970        let tasks_picker = open_spawn_tasks(&workspace, cx);
 971        assert_eq!(
 972            task_names(&tasks_picker, cx),
 973            vec!["Rust task"],
 974            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
 975        );
 976
 977        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
 978        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
 979        let _ts_file_2 = workspace
 980            .update(cx, |workspace, cx| {
 981                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 982            })
 983            .await
 984            .unwrap();
 985        let tasks_picker = open_spawn_tasks(&workspace, cx);
 986        assert_eq!(
 987            task_names(&tasks_picker, cx),
 988            vec![
 989                "TypeScript task from file /dir/a1.ts",
 990                "Another task from file /dir/a2.ts",
 991                "TypeScript task from file /dir/a2.ts",
 992                "Task without variables"
 993            ],
 994            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
 995            same TS spawn history should be restored"
 996        );
 997    }
 998
 999    fn emulate_task_schedule(
1000        tasks_picker: View<Picker<TasksModalDelegate>>,
1001        project: &Model<Project>,
1002        scheduled_task_label: &str,
1003        cx: &mut VisualTestContext,
1004    ) {
1005        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1006            tasks_picker
1007                .delegate
1008                .candidates
1009                .iter()
1010                .flatten()
1011                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1012                .cloned()
1013                .unwrap()
1014        });
1015        project.update(cx, |project, cx| {
1016            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1017                task_inventory.update(cx, |inventory, _| {
1018                    let (kind, task) = scheduled_task;
1019                    inventory.task_scheduled(kind, task);
1020                });
1021            }
1022        });
1023        tasks_picker.update(cx, |_, cx| {
1024            cx.emit(DismissEvent);
1025        });
1026        drop(tasks_picker);
1027        cx.executor().run_until_parked()
1028    }
1029
1030    fn open_spawn_tasks(
1031        workspace: &View<Workspace>,
1032        cx: &mut VisualTestContext,
1033    ) -> View<Picker<TasksModalDelegate>> {
1034        cx.dispatch_action(Spawn::default());
1035        workspace.update(cx, |workspace, cx| {
1036            workspace
1037                .active_modal::<TasksModal>(cx)
1038                .expect("no task modal after `Spawn` action was dispatched")
1039                .read(cx)
1040                .picker
1041                .clone()
1042        })
1043    }
1044
1045    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1046        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1047    }
1048
1049    fn task_names(
1050        spawn_tasks: &View<Picker<TasksModalDelegate>>,
1051        cx: &mut VisualTestContext,
1052    ) -> Vec<String> {
1053        spawn_tasks.update(cx, |spawn_tasks, _| {
1054            spawn_tasks
1055                .delegate
1056                .matches
1057                .iter()
1058                .map(|hit| hit.string.clone())
1059                .collect::<Vec<_>>()
1060        })
1061    }
1062}