modal.rs

   1use std::sync::Arc;
   2
   3use crate::TaskContexts;
   4use fuzzy::{StringMatch, StringMatchCandidate};
   5use gpui::{
   6    rems, Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter,
   7    Focusable, InteractiveElement, ParentElement, Render, SharedString, Styled, Subscription, Task,
   8    WeakEntity, Window,
   9};
  10use picker::{highlighted_match_with_paths::HighlightedMatch, Picker, PickerDelegate};
  11use project::{task_store::TaskStore, TaskSourceKind};
  12use task::{
  13    DebugRequestType, ResolvedTask, RevealTarget, TaskContext, TaskModal, TaskTemplate, TaskType,
  14};
  15use ui::{
  16    div, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color,
  17    FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement,
  18    KeyBinding, LabelSize, ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip,
  19};
  20use util::ResultExt;
  21use workspace::{tasks::schedule_resolved_task, ModalView, Workspace};
  22pub use zed_actions::{Rerun, Spawn};
  23
  24/// A modal used to spawn new tasks.
  25pub(crate) struct TasksModalDelegate {
  26    task_store: Entity<TaskStore>,
  27    candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
  28    task_overrides: Option<TaskOverrides>,
  29    last_used_candidate_index: Option<usize>,
  30    divider_index: Option<usize>,
  31    matches: Vec<StringMatch>,
  32    selected_index: usize,
  33    workspace: WeakEntity<Workspace>,
  34    prompt: String,
  35    task_contexts: TaskContexts,
  36    placeholder_text: Arc<str>,
  37    /// If this delegate is responsible for running a scripting task or a debugger
  38    task_modal_type: TaskModal,
  39}
  40
  41/// Task template amendments to do before resolving the context.
  42#[derive(Clone, Debug, Default, PartialEq, Eq)]
  43pub(crate) struct TaskOverrides {
  44    /// See [`RevealTarget`].
  45    pub(crate) reveal_target: Option<RevealTarget>,
  46}
  47
  48impl TasksModalDelegate {
  49    fn new(
  50        task_store: Entity<TaskStore>,
  51        task_contexts: TaskContexts,
  52        task_overrides: Option<TaskOverrides>,
  53        task_modal_type: TaskModal,
  54        workspace: WeakEntity<Workspace>,
  55    ) -> Self {
  56        let placeholder_text = if let Some(TaskOverrides {
  57            reveal_target: Some(RevealTarget::Center),
  58        }) = &task_overrides
  59        {
  60            Arc::from("Find a task, or run a command in the central pane")
  61        } else {
  62            Arc::from("Find a task, or run a command")
  63        };
  64        Self {
  65            task_store,
  66            workspace,
  67            candidates: None,
  68            matches: Vec::new(),
  69            last_used_candidate_index: None,
  70            divider_index: None,
  71            selected_index: 0,
  72            prompt: String::default(),
  73            task_contexts,
  74            task_modal_type,
  75            task_overrides,
  76            placeholder_text,
  77        }
  78    }
  79
  80    fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
  81        if self.prompt.trim().is_empty() {
  82            return None;
  83        }
  84
  85        let default_context = TaskContext::default();
  86        let active_context = self
  87            .task_contexts
  88            .active_context()
  89            .unwrap_or(&default_context);
  90        let source_kind = TaskSourceKind::UserInput;
  91        let id_base = source_kind.to_id_base();
  92        let mut new_oneshot = TaskTemplate {
  93            label: self.prompt.clone(),
  94            command: self.prompt.clone(),
  95            ..TaskTemplate::default()
  96        };
  97        if let Some(TaskOverrides {
  98            reveal_target: Some(reveal_target),
  99        }) = &self.task_overrides
 100        {
 101            new_oneshot.reveal_target = *reveal_target;
 102        }
 103        Some((
 104            source_kind,
 105            new_oneshot.resolve_task(&id_base, active_context)?,
 106        ))
 107    }
 108
 109    fn delete_previously_used(&mut self, ix: usize, cx: &mut App) {
 110        let Some(candidates) = self.candidates.as_mut() else {
 111            return;
 112        };
 113        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
 114            return;
 115        };
 116        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
 117        // 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
 118        // the original list without a removed entry.
 119        candidates.remove(ix);
 120        if let Some(inventory) = self.task_store.read(cx).task_inventory().cloned() {
 121            inventory.update(cx, |inventory, _| {
 122                inventory.delete_previously_used(&task.id);
 123            })
 124        };
 125    }
 126}
 127
 128pub(crate) struct TasksModal {
 129    picker: Entity<Picker<TasksModalDelegate>>,
 130    _subscription: Subscription,
 131}
 132
 133impl TasksModal {
 134    pub(crate) fn new(
 135        task_store: Entity<TaskStore>,
 136        task_contexts: TaskContexts,
 137        task_overrides: Option<TaskOverrides>,
 138        workspace: WeakEntity<Workspace>,
 139        task_modal_type: TaskModal,
 140        window: &mut Window,
 141        cx: &mut Context<Self>,
 142    ) -> Self {
 143        let picker = cx.new(|cx| {
 144            Picker::uniform_list(
 145                TasksModalDelegate::new(
 146                    task_store,
 147                    task_contexts,
 148                    task_overrides,
 149                    task_modal_type,
 150                    workspace,
 151                ),
 152                window,
 153                cx,
 154            )
 155        });
 156        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
 157            cx.emit(DismissEvent);
 158        });
 159        Self {
 160            picker,
 161            _subscription,
 162        }
 163    }
 164}
 165
 166impl Render for TasksModal {
 167    fn render(
 168        &mut self,
 169        _window: &mut Window,
 170        _: &mut Context<Self>,
 171    ) -> impl gpui::prelude::IntoElement {
 172        v_flex()
 173            .key_context("TasksModal")
 174            .w(rems(34.))
 175            .child(self.picker.clone())
 176    }
 177}
 178
 179impl EventEmitter<DismissEvent> for TasksModal {}
 180
 181impl Focusable for TasksModal {
 182    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
 183        self.picker.read(cx).focus_handle(cx)
 184    }
 185}
 186
 187impl ModalView for TasksModal {}
 188
 189impl PickerDelegate for TasksModalDelegate {
 190    type ListItem = ListItem;
 191
 192    fn match_count(&self) -> usize {
 193        self.matches.len()
 194    }
 195
 196    fn selected_index(&self) -> usize {
 197        self.selected_index
 198    }
 199
 200    fn set_selected_index(
 201        &mut self,
 202        ix: usize,
 203        _window: &mut Window,
 204        _cx: &mut Context<picker::Picker<Self>>,
 205    ) {
 206        self.selected_index = ix;
 207    }
 208
 209    fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc<str> {
 210        self.placeholder_text.clone()
 211    }
 212
 213    fn update_matches(
 214        &mut self,
 215        query: String,
 216        window: &mut Window,
 217        cx: &mut Context<picker::Picker<Self>>,
 218    ) -> Task<()> {
 219        let task_type = self.task_modal_type.clone();
 220        cx.spawn_in(window, async move |picker, cx| {
 221            let Some(candidates) = picker
 222                .update(cx, |picker, cx| match &mut picker.delegate.candidates {
 223                    Some(candidates) => string_match_candidates(candidates.iter(), task_type),
 224                    None => {
 225                        let Some(task_inventory) = picker
 226                            .delegate
 227                            .task_store
 228                            .read(cx)
 229                            .task_inventory()
 230                            .cloned()
 231                        else {
 232                            return Vec::new();
 233                        };
 234
 235                        let (used, current) = task_inventory
 236                            .read(cx)
 237                            .used_and_current_resolved_tasks(&picker.delegate.task_contexts, cx);
 238                        picker.delegate.last_used_candidate_index = if used.is_empty() {
 239                            None
 240                        } else {
 241                            Some(used.len() - 1)
 242                        };
 243
 244                        let mut new_candidates = used;
 245                        new_candidates.extend(current);
 246                        let match_candidates =
 247                            string_match_candidates(new_candidates.iter(), task_type);
 248                        let _ = picker.delegate.candidates.insert(new_candidates);
 249                        match_candidates
 250                    }
 251                })
 252                .ok()
 253            else {
 254                return;
 255            };
 256            let matches = fuzzy::match_strings(
 257                &candidates,
 258                &query,
 259                true,
 260                1000,
 261                &Default::default(),
 262                cx.background_executor().clone(),
 263            )
 264            .await;
 265            picker
 266                .update(cx, |picker, _| {
 267                    let delegate = &mut picker.delegate;
 268                    delegate.matches = matches;
 269                    if let Some(index) = delegate.last_used_candidate_index {
 270                        delegate.matches.sort_by_key(|m| m.candidate_id > index);
 271                    }
 272
 273                    delegate.prompt = query;
 274                    delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
 275                        let index = delegate
 276                            .matches
 277                            .partition_point(|matching_task| matching_task.candidate_id <= index);
 278                        Some(index).and_then(|index| (index != 0).then(|| index - 1))
 279                    });
 280
 281                    if delegate.matches.is_empty() {
 282                        delegate.selected_index = 0;
 283                    } else {
 284                        delegate.selected_index =
 285                            delegate.selected_index.min(delegate.matches.len() - 1);
 286                    }
 287                })
 288                .log_err();
 289        })
 290    }
 291
 292    fn confirm(
 293        &mut self,
 294        omit_history_entry: bool,
 295        window: &mut Window,
 296        cx: &mut Context<picker::Picker<Self>>,
 297    ) {
 298        let current_match_index = self.selected_index();
 299        let task = self
 300            .matches
 301            .get(current_match_index)
 302            .and_then(|current_match| {
 303                let ix = current_match.candidate_id;
 304                self.candidates
 305                    .as_ref()
 306                    .map(|candidates| candidates[ix].clone())
 307            });
 308        let Some((task_source_kind, mut task)) = task else {
 309            return;
 310        };
 311        if let Some(TaskOverrides {
 312            reveal_target: Some(reveal_target),
 313        }) = &self.task_overrides
 314        {
 315            if let Some(resolved_task) = &mut task.resolved {
 316                resolved_task.reveal_target = *reveal_target;
 317            }
 318        }
 319
 320        self.workspace
 321            .update(cx, |workspace, cx| {
 322                match task.task_type() {
 323                    TaskType::Script => schedule_resolved_task(
 324                        workspace,
 325                        task_source_kind,
 326                        task,
 327                        omit_history_entry,
 328                        cx,
 329                    ),
 330                    TaskType::Debug(_) => {
 331                        let Some(config) = task.resolved_debug_adapter_config() else {
 332                            return;
 333                        };
 334                        let project = workspace.project().clone();
 335
 336                        match &config.request {
 337                            DebugRequestType::Attach(attach_config)
 338                                if attach_config.process_id.is_none() =>
 339                            {
 340                                workspace.toggle_modal(window, cx, |window, cx| {
 341                                    debugger_ui::attach_modal::AttachModal::new(
 342                                        project,
 343                                        config.clone(),
 344                                        window,
 345                                        cx,
 346                                    )
 347                                });
 348                            }
 349                            _ => {
 350                                project.update(cx, |project, cx| {
 351                                    project
 352                                        .start_debug_session(config.into(), cx)
 353                                        .detach_and_log_err(cx);
 354                                });
 355                            }
 356                        }
 357                    }
 358                };
 359            })
 360            .ok();
 361        cx.emit(DismissEvent);
 362    }
 363
 364    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
 365        cx.emit(DismissEvent);
 366    }
 367
 368    fn render_match(
 369        &self,
 370        ix: usize,
 371        selected: bool,
 372        window: &mut Window,
 373        cx: &mut Context<picker::Picker<Self>>,
 374    ) -> Option<Self::ListItem> {
 375        let candidates = self.candidates.as_ref()?;
 376        let hit = &self.matches[ix];
 377        let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
 378        let template = resolved_task.original_task();
 379        let display_label = resolved_task.display_label();
 380
 381        let mut tooltip_label_text = if display_label != &template.label {
 382            resolved_task.resolved_label.clone()
 383        } else {
 384            String::new()
 385        };
 386        if let Some(resolved) = resolved_task.resolved.as_ref() {
 387            if resolved.command_label != display_label
 388                && resolved.command_label != resolved_task.resolved_label
 389            {
 390                if !tooltip_label_text.trim().is_empty() {
 391                    tooltip_label_text.push('\n');
 392                }
 393                tooltip_label_text.push_str(&resolved.command_label);
 394            }
 395        }
 396        let tooltip_label = if tooltip_label_text.trim().is_empty() {
 397            None
 398        } else {
 399            Some(Tooltip::simple(tooltip_label_text, cx))
 400        };
 401
 402        let highlighted_location = HighlightedMatch {
 403            text: hit.string.clone(),
 404            highlight_positions: hit.positions.clone(),
 405            char_count: hit.string.chars().count(),
 406            color: Color::Default,
 407        };
 408        let icon = match source_kind {
 409            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
 410            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
 411            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
 412            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
 413                .get_icon_for_type(&name.to_lowercase(), cx)
 414                .map(Icon::from_path),
 415        }
 416        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 417        let history_run_icon = if Some(ix) <= self.divider_index {
 418            Some(
 419                Icon::new(IconName::HistoryRerun)
 420                    .color(Color::Muted)
 421                    .size(IconSize::Small)
 422                    .into_any_element(),
 423            )
 424        } else {
 425            Some(
 426                v_flex()
 427                    .flex_none()
 428                    .size(IconSize::Small.rems())
 429                    .into_any_element(),
 430            )
 431        };
 432
 433        Some(
 434            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 435                .inset(true)
 436                .start_slot::<Icon>(icon)
 437                .end_slot::<AnyElement>(history_run_icon)
 438                .spacing(ListItemSpacing::Sparse)
 439                .when_some(tooltip_label, |list_item, item_label| {
 440                    list_item.tooltip(move |_, _| item_label.clone())
 441                })
 442                .map(|item| {
 443                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
 444                        || Some(ix) <= self.divider_index
 445                    {
 446                        let task_index = hit.candidate_id;
 447                        let delete_button = div().child(
 448                            IconButton::new("delete", IconName::Close)
 449                                .shape(IconButtonShape::Square)
 450                                .icon_color(Color::Muted)
 451                                .size(ButtonSize::None)
 452                                .icon_size(IconSize::XSmall)
 453                                .on_click(cx.listener(move |picker, _event, window, cx| {
 454                                    cx.stop_propagation();
 455                                    window.prevent_default();
 456
 457                                    picker.delegate.delete_previously_used(task_index, cx);
 458                                    picker.delegate.last_used_candidate_index = picker
 459                                        .delegate
 460                                        .last_used_candidate_index
 461                                        .unwrap_or(0)
 462                                        .checked_sub(1);
 463                                    picker.refresh(window, cx);
 464                                }))
 465                                .tooltip(|_, cx| {
 466                                    Tooltip::simple("Delete Previously Scheduled Task", cx)
 467                                }),
 468                        );
 469                        item.end_hover_slot(delete_button)
 470                    } else {
 471                        item
 472                    };
 473                    item
 474                })
 475                .toggle_state(selected)
 476                .child(highlighted_location.render(window, cx)),
 477        )
 478    }
 479
 480    fn confirm_completion(
 481        &mut self,
 482        _: String,
 483        _window: &mut Window,
 484        _: &mut Context<Picker<Self>>,
 485    ) -> Option<String> {
 486        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 487        let tasks = self.candidates.as_ref()?;
 488        let (_, task) = tasks.get(task_index)?;
 489        Some(task.resolved.as_ref()?.command_label.clone())
 490    }
 491
 492    fn confirm_input(
 493        &mut self,
 494        omit_history_entry: bool,
 495        _: &mut Window,
 496        cx: &mut Context<Picker<Self>>,
 497    ) {
 498        let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
 499            return;
 500        };
 501
 502        if let Some(TaskOverrides {
 503            reveal_target: Some(reveal_target),
 504        }) = self.task_overrides
 505        {
 506            if let Some(resolved_task) = &mut task.resolved {
 507                resolved_task.reveal_target = reveal_target;
 508            }
 509        }
 510        self.workspace
 511            .update(cx, |workspace, cx| {
 512                match task.task_type() {
 513                    TaskType::Script => schedule_resolved_task(
 514                        workspace,
 515                        task_source_kind,
 516                        task,
 517                        omit_history_entry,
 518                        cx,
 519                    ),
 520                    // TODO: Should create a schedule_resolved_debug_task function
 521                    // This would allow users to access to debug history and other issues
 522                    TaskType::Debug(_) => workspace.project().update(cx, |project, cx| {
 523                        project
 524                            .start_debug_session(
 525                                task.resolved_debug_adapter_config().unwrap().into(),
 526                                cx,
 527                            )
 528                            .detach_and_log_err(cx);
 529                    }),
 530                };
 531            })
 532            .ok();
 533        cx.emit(DismissEvent);
 534    }
 535
 536    fn separators_after_indices(&self) -> Vec<usize> {
 537        if let Some(i) = self.divider_index {
 538            vec![i]
 539        } else {
 540            Vec::new()
 541        }
 542    }
 543    fn render_footer(
 544        &self,
 545        window: &mut Window,
 546        cx: &mut Context<Picker<Self>>,
 547    ) -> Option<gpui::AnyElement> {
 548        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 549        let current_modifiers = window.modifiers();
 550        let left_button = if self
 551            .task_store
 552            .read(cx)
 553            .task_inventory()?
 554            .read(cx)
 555            .last_scheduled_task(None)
 556            .is_some()
 557        {
 558            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 559        } else {
 560            None
 561        };
 562        Some(
 563            h_flex()
 564                .w_full()
 565                .h_8()
 566                .p_2()
 567                .justify_between()
 568                .rounded_b_sm()
 569                .bg(cx.theme().colors().ghost_element_selected)
 570                .border_t_1()
 571                .border_color(cx.theme().colors().border_variant)
 572                .child(
 573                    left_button
 574                        .map(|(label, action)| {
 575                            let keybind = KeyBinding::for_action(&*action, window, cx);
 576
 577                            Button::new("edit-current-task", label)
 578                                .label_size(LabelSize::Small)
 579                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 580                                .on_click(move |_, window, cx| {
 581                                    window.dispatch_action(action.boxed_clone(), cx);
 582                                })
 583                                .into_any_element()
 584                        })
 585                        .unwrap_or_else(|| h_flex().into_any_element()),
 586                )
 587                .map(|this| {
 588                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 589                    {
 590                        let action = picker::ConfirmInput {
 591                            secondary: current_modifiers.secondary(),
 592                        }
 593                        .boxed_clone();
 594                        this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
 595                            let spawn_oneshot_label = if current_modifiers.secondary() {
 596                                "Spawn Oneshot Without History"
 597                            } else {
 598                                "Spawn Oneshot"
 599                            };
 600
 601                            Button::new("spawn-onehshot", spawn_oneshot_label)
 602                                .label_size(LabelSize::Small)
 603                                .key_binding(keybind)
 604                                .on_click(move |_, window, cx| {
 605                                    window.dispatch_action(action.boxed_clone(), cx)
 606                                })
 607                        }))
 608                    } else if current_modifiers.secondary() {
 609                        this.children(
 610                            KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
 611                                |keybind| {
 612                                    let label = if is_recent_selected {
 613                                        "Rerun Without History"
 614                                    } else {
 615                                        "Spawn Without History"
 616                                    };
 617                                    Button::new("spawn", label)
 618                                        .label_size(LabelSize::Small)
 619                                        .key_binding(keybind)
 620                                        .on_click(move |_, window, cx| {
 621                                            window.dispatch_action(
 622                                                menu::SecondaryConfirm.boxed_clone(),
 623                                                cx,
 624                                            )
 625                                        })
 626                                },
 627                            ),
 628                        )
 629                    } else {
 630                        this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
 631                            |keybind| {
 632                                let run_entry_label =
 633                                    if is_recent_selected { "Rerun" } else { "Spawn" };
 634
 635                                Button::new("spawn", run_entry_label)
 636                                    .label_size(LabelSize::Small)
 637                                    .key_binding(keybind)
 638                                    .on_click(|_, window, cx| {
 639                                        window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 640                                    })
 641                            },
 642                        ))
 643                    }
 644                })
 645                .into_any_element(),
 646        )
 647    }
 648}
 649
 650fn string_match_candidates<'a>(
 651    candidates: impl Iterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 652    task_modal_type: TaskModal,
 653) -> Vec<StringMatchCandidate> {
 654    candidates
 655        .enumerate()
 656        .filter(|(_, (_, candidate))| match candidate.task_type() {
 657            TaskType::Script => task_modal_type == TaskModal::ScriptModal,
 658            TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal,
 659        })
 660        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 661        .collect()
 662}
 663
 664#[cfg(test)]
 665mod tests {
 666    use std::{path::PathBuf, sync::Arc};
 667
 668    use editor::Editor;
 669    use gpui::{TestAppContext, VisualTestContext};
 670    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 671    use project::{ContextProviderWithTasks, FakeFs, Project};
 672    use serde_json::json;
 673    use task::TaskTemplates;
 674    use util::path;
 675    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 676
 677    use crate::{modal::Spawn, tests::init_test};
 678
 679    use super::*;
 680
 681    #[gpui::test]
 682    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 683        init_test(cx);
 684        let fs = FakeFs::new(cx.executor());
 685        fs.insert_tree(
 686            path!("/dir"),
 687            json!({
 688                ".zed": {
 689                    "tasks.json": r#"[
 690                        {
 691                            "label": "example task",
 692                            "command": "echo",
 693                            "args": ["4"]
 694                        },
 695                        {
 696                            "label": "another one",
 697                            "command": "echo",
 698                            "args": ["55"]
 699                        },
 700                    ]"#,
 701                },
 702                "a.ts": "a"
 703            }),
 704        )
 705        .await;
 706
 707        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 708        let (workspace, cx) =
 709            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 710
 711        let tasks_picker = open_spawn_tasks(&workspace, cx);
 712        assert_eq!(
 713            query(&tasks_picker, cx),
 714            "",
 715            "Initial query should be empty"
 716        );
 717        assert_eq!(
 718            task_names(&tasks_picker, cx),
 719            vec!["another one", "example task"],
 720            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 721        );
 722        drop(tasks_picker);
 723
 724        let _ = workspace
 725            .update_in(cx, |workspace, window, cx| {
 726                workspace.open_abs_path(
 727                    PathBuf::from(path!("/dir/a.ts")),
 728                    OpenOptions {
 729                        visible: Some(OpenVisible::All),
 730                        ..Default::default()
 731                    },
 732                    window,
 733                    cx,
 734                )
 735            })
 736            .await
 737            .unwrap();
 738        let tasks_picker = open_spawn_tasks(&workspace, cx);
 739        assert_eq!(
 740            task_names(&tasks_picker, cx),
 741            vec!["another one", "example task"],
 742            "Initial tasks should be listed in alphabetical order"
 743        );
 744
 745        let query_str = "tas";
 746        cx.simulate_input(query_str);
 747        assert_eq!(query(&tasks_picker, cx), query_str);
 748        assert_eq!(
 749            task_names(&tasks_picker, cx),
 750            vec!["example task"],
 751            "Only one task should match the query {query_str}"
 752        );
 753
 754        cx.dispatch_action(picker::ConfirmCompletion);
 755        assert_eq!(
 756            query(&tasks_picker, cx),
 757            "echo 4",
 758            "Query should be set to the selected task's command"
 759        );
 760        assert_eq!(
 761            task_names(&tasks_picker, cx),
 762            Vec::<String>::new(),
 763            "No task should be listed"
 764        );
 765        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 766
 767        let tasks_picker = open_spawn_tasks(&workspace, cx);
 768        assert_eq!(
 769            query(&tasks_picker, cx),
 770            "",
 771            "Query should be reset after confirming"
 772        );
 773        assert_eq!(
 774            task_names(&tasks_picker, cx),
 775            vec!["echo 4", "another one", "example task"],
 776            "New oneshot task should be listed first"
 777        );
 778
 779        let query_str = "echo 4";
 780        cx.simulate_input(query_str);
 781        assert_eq!(query(&tasks_picker, cx), query_str);
 782        assert_eq!(
 783            task_names(&tasks_picker, cx),
 784            vec!["echo 4"],
 785            "New oneshot should match custom command query"
 786        );
 787
 788        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 789        let tasks_picker = open_spawn_tasks(&workspace, cx);
 790        assert_eq!(
 791            query(&tasks_picker, cx),
 792            "",
 793            "Query should be reset after confirming"
 794        );
 795        assert_eq!(
 796            task_names(&tasks_picker, cx),
 797            vec![query_str, "another one", "example task"],
 798            "Last recently used one show task should be listed first"
 799        );
 800
 801        cx.dispatch_action(picker::ConfirmCompletion);
 802        assert_eq!(
 803            query(&tasks_picker, cx),
 804            query_str,
 805            "Query should be set to the custom task's name"
 806        );
 807        assert_eq!(
 808            task_names(&tasks_picker, cx),
 809            vec![query_str],
 810            "Only custom task should be listed"
 811        );
 812
 813        let query_str = "0";
 814        cx.simulate_input(query_str);
 815        assert_eq!(query(&tasks_picker, cx), "echo 40");
 816        assert_eq!(
 817            task_names(&tasks_picker, cx),
 818            Vec::<String>::new(),
 819            "New oneshot should not match any command query"
 820        );
 821
 822        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 823        let tasks_picker = open_spawn_tasks(&workspace, cx);
 824        assert_eq!(
 825            query(&tasks_picker, cx),
 826            "",
 827            "Query should be reset after confirming"
 828        );
 829        assert_eq!(
 830            task_names(&tasks_picker, cx),
 831            vec!["echo 4", "another one", "example task"],
 832            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 833        );
 834
 835        cx.dispatch_action(Spawn::ByName {
 836            task_name: "example task".to_string(),
 837            reveal_target: None,
 838        });
 839        let tasks_picker = workspace.update(cx, |workspace, cx| {
 840            workspace
 841                .active_modal::<TasksModal>(cx)
 842                .unwrap()
 843                .read(cx)
 844                .picker
 845                .clone()
 846        });
 847        assert_eq!(
 848            task_names(&tasks_picker, cx),
 849            vec!["echo 4", "another one", "example task"],
 850        );
 851    }
 852
 853    #[gpui::test]
 854    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 855        init_test(cx);
 856        let fs = FakeFs::new(cx.executor());
 857        fs.insert_tree(
 858            path!("/dir"),
 859            json!({
 860                ".zed": {
 861                    "tasks.json": r#"[
 862                        {
 863                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 864                            "command": "echo",
 865                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 866                        },
 867                        {
 868                            "label": "opened now: $ZED_WORKTREE_ROOT",
 869                            "command": "echo",
 870                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 871                        }
 872                    ]"#,
 873                },
 874                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 875                "file_with.odd_extension": "b",
 876            }),
 877        )
 878        .await;
 879
 880        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 881        let (workspace, cx) =
 882            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 883
 884        let tasks_picker = open_spawn_tasks(&workspace, cx);
 885        assert_eq!(
 886            task_names(&tasks_picker, cx),
 887            vec![concat!("opened now: ", path!("/dir")).to_string()],
 888            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 889        );
 890        tasks_picker.update(cx, |_, cx| {
 891            cx.emit(DismissEvent);
 892        });
 893        drop(tasks_picker);
 894        cx.executor().run_until_parked();
 895
 896        let _ = workspace
 897            .update_in(cx, |workspace, window, cx| {
 898                workspace.open_abs_path(
 899                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 900                    OpenOptions {
 901                        visible: Some(OpenVisible::All),
 902                        ..Default::default()
 903                    },
 904                    window,
 905                    cx,
 906                )
 907            })
 908            .await
 909            .unwrap();
 910        cx.executor().run_until_parked();
 911        let tasks_picker = open_spawn_tasks(&workspace, cx);
 912        assert_eq!(
 913            task_names(&tasks_picker, cx),
 914            vec![
 915                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 916                concat!("opened now: ", path!("/dir")).to_string(),
 917            ],
 918            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 919        );
 920        tasks_picker.update(cx, |_, cx| {
 921            cx.emit(DismissEvent);
 922        });
 923        drop(tasks_picker);
 924        cx.executor().run_until_parked();
 925
 926        let second_item = workspace
 927            .update_in(cx, |workspace, window, cx| {
 928                workspace.open_abs_path(
 929                    PathBuf::from(path!("/dir/file_without_extension")),
 930                    OpenOptions {
 931                        visible: Some(OpenVisible::All),
 932                        ..Default::default()
 933                    },
 934                    window,
 935                    cx,
 936                )
 937            })
 938            .await
 939            .unwrap();
 940
 941        let editor = cx
 942            .update(|_window, cx| second_item.act_as::<Editor>(cx))
 943            .unwrap();
 944        editor.update_in(cx, |editor, window, cx| {
 945            editor.change_selections(None, window, cx, |s| {
 946                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 947            })
 948        });
 949        cx.executor().run_until_parked();
 950        let tasks_picker = open_spawn_tasks(&workspace, cx);
 951        assert_eq!(
 952            task_names(&tasks_picker, cx),
 953            vec![
 954                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
 955                concat!("opened now: ", path!("/dir")).to_string(),
 956            ],
 957            "Opened buffer should fill the context, labels should be trimmed if long enough"
 958        );
 959        tasks_picker.update(cx, |_, cx| {
 960            cx.emit(DismissEvent);
 961        });
 962        drop(tasks_picker);
 963        cx.executor().run_until_parked();
 964    }
 965
 966    #[gpui::test]
 967    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 968        init_test(cx);
 969        let fs = FakeFs::new(cx.executor());
 970        fs.insert_tree(
 971            path!("/dir"),
 972            json!({
 973                "a1.ts": "// a1",
 974                "a2.ts": "// a2",
 975                "b.rs": "// b",
 976            }),
 977        )
 978        .await;
 979
 980        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 981        project.read_with(cx, |project, _| {
 982            let language_registry = project.languages();
 983            language_registry.add(Arc::new(
 984                Language::new(
 985                    LanguageConfig {
 986                        name: "TypeScript".into(),
 987                        matcher: LanguageMatcher {
 988                            path_suffixes: vec!["ts".to_string()],
 989                            ..LanguageMatcher::default()
 990                        },
 991                        ..LanguageConfig::default()
 992                    },
 993                    None,
 994                )
 995                .with_context_provider(Some(Arc::new(
 996                    ContextProviderWithTasks::new(TaskTemplates(vec![
 997                        TaskTemplate {
 998                            label: "Task without variables".to_string(),
 999                            command: "npm run clean".to_string(),
1000                            ..TaskTemplate::default()
1001                        },
1002                        TaskTemplate {
1003                            label: "TypeScript task from file $ZED_FILE".to_string(),
1004                            command: "npm run build".to_string(),
1005                            ..TaskTemplate::default()
1006                        },
1007                        TaskTemplate {
1008                            label: "Another task from file $ZED_FILE".to_string(),
1009                            command: "npm run lint".to_string(),
1010                            ..TaskTemplate::default()
1011                        },
1012                    ])),
1013                ))),
1014            ));
1015            language_registry.add(Arc::new(
1016                Language::new(
1017                    LanguageConfig {
1018                        name: "Rust".into(),
1019                        matcher: LanguageMatcher {
1020                            path_suffixes: vec!["rs".to_string()],
1021                            ..LanguageMatcher::default()
1022                        },
1023                        ..LanguageConfig::default()
1024                    },
1025                    None,
1026                )
1027                .with_context_provider(Some(Arc::new(
1028                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1029                        label: "Rust task".to_string(),
1030                        command: "cargo check".into(),
1031                        ..TaskTemplate::default()
1032                    }])),
1033                ))),
1034            ));
1035        });
1036        let (workspace, cx) =
1037            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1038
1039        let _ts_file_1 = workspace
1040            .update_in(cx, |workspace, window, cx| {
1041                workspace.open_abs_path(
1042                    PathBuf::from(path!("/dir/a1.ts")),
1043                    OpenOptions {
1044                        visible: Some(OpenVisible::All),
1045                        ..Default::default()
1046                    },
1047                    window,
1048                    cx,
1049                )
1050            })
1051            .await
1052            .unwrap();
1053        let tasks_picker = open_spawn_tasks(&workspace, cx);
1054        assert_eq!(
1055            task_names(&tasks_picker, cx),
1056            vec![
1057                concat!("Another task from file ", path!("/dir/a1.ts")),
1058                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1059                "Task without variables",
1060            ],
1061            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1062        );
1063
1064        emulate_task_schedule(
1065            tasks_picker,
1066            &project,
1067            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1068            cx,
1069        );
1070
1071        let tasks_picker = open_spawn_tasks(&workspace, cx);
1072        assert_eq!(
1073            task_names(&tasks_picker, cx),
1074            vec![
1075                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1076                concat!("Another task from file ", path!("/dir/a1.ts")),
1077                "Task without variables",
1078            ],
1079            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1080            Tasks with the same labels and context are deduplicated."
1081        );
1082        tasks_picker.update(cx, |_, cx| {
1083            cx.emit(DismissEvent);
1084        });
1085        drop(tasks_picker);
1086        cx.executor().run_until_parked();
1087
1088        let _ts_file_2 = workspace
1089            .update_in(cx, |workspace, window, cx| {
1090                workspace.open_abs_path(
1091                    PathBuf::from(path!("/dir/a2.ts")),
1092                    OpenOptions {
1093                        visible: Some(OpenVisible::All),
1094                        ..Default::default()
1095                    },
1096                    window,
1097                    cx,
1098                )
1099            })
1100            .await
1101            .unwrap();
1102        let tasks_picker = open_spawn_tasks(&workspace, cx);
1103        assert_eq!(
1104            task_names(&tasks_picker, cx),
1105            vec![
1106                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1107                concat!("Another task from file ", path!("/dir/a2.ts")),
1108                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1109                "Task without variables",
1110            ],
1111            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1112        );
1113        tasks_picker.update(cx, |_, cx| {
1114            cx.emit(DismissEvent);
1115        });
1116        drop(tasks_picker);
1117        cx.executor().run_until_parked();
1118
1119        let _rs_file = workspace
1120            .update_in(cx, |workspace, window, cx| {
1121                workspace.open_abs_path(
1122                    PathBuf::from(path!("/dir/b.rs")),
1123                    OpenOptions {
1124                        visible: Some(OpenVisible::All),
1125                        ..Default::default()
1126                    },
1127                    window,
1128                    cx,
1129                )
1130            })
1131            .await
1132            .unwrap();
1133        let tasks_picker = open_spawn_tasks(&workspace, cx);
1134        assert_eq!(
1135            task_names(&tasks_picker, cx),
1136            vec!["Rust task"],
1137            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1138        );
1139
1140        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1141        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1142        let _ts_file_2 = workspace
1143            .update_in(cx, |workspace, window, cx| {
1144                workspace.open_abs_path(
1145                    PathBuf::from(path!("/dir/a2.ts")),
1146                    OpenOptions {
1147                        visible: Some(OpenVisible::All),
1148                        ..Default::default()
1149                    },
1150                    window,
1151                    cx,
1152                )
1153            })
1154            .await
1155            .unwrap();
1156        let tasks_picker = open_spawn_tasks(&workspace, cx);
1157        assert_eq!(
1158            task_names(&tasks_picker, cx),
1159            vec![
1160                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1161                concat!("Another task from file ", path!("/dir/a2.ts")),
1162                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1163                "Task without variables",
1164            ],
1165            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1166            same TS spawn history should be restored"
1167        );
1168    }
1169
1170    fn emulate_task_schedule(
1171        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1172        project: &Entity<Project>,
1173        scheduled_task_label: &str,
1174        cx: &mut VisualTestContext,
1175    ) {
1176        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1177            tasks_picker
1178                .delegate
1179                .candidates
1180                .iter()
1181                .flatten()
1182                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1183                .cloned()
1184                .unwrap()
1185        });
1186        project.update(cx, |project, cx| {
1187            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1188                task_inventory.update(cx, |inventory, _| {
1189                    let (kind, task) = scheduled_task;
1190                    inventory.task_scheduled(kind, task);
1191                });
1192            }
1193        });
1194        tasks_picker.update(cx, |_, cx| {
1195            cx.emit(DismissEvent);
1196        });
1197        drop(tasks_picker);
1198        cx.executor().run_until_parked()
1199    }
1200
1201    fn open_spawn_tasks(
1202        workspace: &Entity<Workspace>,
1203        cx: &mut VisualTestContext,
1204    ) -> Entity<Picker<TasksModalDelegate>> {
1205        cx.dispatch_action(Spawn::modal());
1206        workspace.update(cx, |workspace, cx| {
1207            workspace
1208                .active_modal::<TasksModal>(cx)
1209                .expect("no task modal after `Spawn` action was dispatched")
1210                .read(cx)
1211                .picker
1212                .clone()
1213        })
1214    }
1215
1216    fn query(
1217        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1218        cx: &mut VisualTestContext,
1219    ) -> String {
1220        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1221    }
1222
1223    fn task_names(
1224        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1225        cx: &mut VisualTestContext,
1226    ) -> Vec<String> {
1227        spawn_tasks.update(cx, |spawn_tasks, _| {
1228            spawn_tasks
1229                .delegate
1230                .matches
1231                .iter()
1232                .map(|hit| hit.string.clone())
1233                .collect::<Vec<_>>()
1234        })
1235    }
1236}