modal.rs

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