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                                        window,
 342                                        cx,
 343                                    )
 344                                });
 345                            }
 346                            _ => {
 347                                project.update(cx, |project, cx| {
 348                                    project
 349                                        .start_debug_session(config.into(), cx)
 350                                        .detach_and_log_err(cx);
 351                                });
 352                            }
 353                        }
 354                    }
 355                    _ => schedule_resolved_task(
 356                        workspace,
 357                        task_source_kind,
 358                        task,
 359                        omit_history_entry,
 360                        cx,
 361                    ),
 362                };
 363            })
 364            .ok();
 365        cx.emit(DismissEvent);
 366    }
 367
 368    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
 369        cx.emit(DismissEvent);
 370    }
 371
 372    fn render_match(
 373        &self,
 374        ix: usize,
 375        selected: bool,
 376        window: &mut Window,
 377        cx: &mut Context<picker::Picker<Self>>,
 378    ) -> Option<Self::ListItem> {
 379        let candidates = self.candidates.as_ref()?;
 380        let hit = &self.matches[ix];
 381        let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
 382        let template = resolved_task.original_task();
 383        let display_label = resolved_task.display_label();
 384
 385        let mut tooltip_label_text = if display_label != &template.label {
 386            resolved_task.resolved_label.clone()
 387        } else {
 388            String::new()
 389        };
 390        if let Some(resolved) = resolved_task.resolved.as_ref() {
 391            if resolved.command_label != display_label
 392                && resolved.command_label != resolved_task.resolved_label
 393            {
 394                if !tooltip_label_text.trim().is_empty() {
 395                    tooltip_label_text.push('\n');
 396                }
 397                tooltip_label_text.push_str(&resolved.command_label);
 398            }
 399        }
 400        let tooltip_label = if tooltip_label_text.trim().is_empty() {
 401            None
 402        } else {
 403            Some(Tooltip::simple(tooltip_label_text, cx))
 404        };
 405
 406        let highlighted_location = HighlightedMatch {
 407            text: hit.string.clone(),
 408            highlight_positions: hit.positions.clone(),
 409            char_count: hit.string.chars().count(),
 410            color: Color::Default,
 411        };
 412        let icon = match source_kind {
 413            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
 414            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
 415            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
 416            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
 417                .get_icon_for_type(&name.to_lowercase(), cx)
 418                .map(Icon::from_path),
 419        }
 420        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 421        let history_run_icon = if Some(ix) <= self.divider_index {
 422            Some(
 423                Icon::new(IconName::HistoryRerun)
 424                    .color(Color::Muted)
 425                    .size(IconSize::Small)
 426                    .into_any_element(),
 427            )
 428        } else {
 429            Some(
 430                v_flex()
 431                    .flex_none()
 432                    .size(IconSize::Small.rems())
 433                    .into_any_element(),
 434            )
 435        };
 436
 437        Some(
 438            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 439                .inset(true)
 440                .start_slot::<Icon>(icon)
 441                .end_slot::<AnyElement>(history_run_icon)
 442                .spacing(ListItemSpacing::Sparse)
 443                .when_some(tooltip_label, |list_item, item_label| {
 444                    list_item.tooltip(move |_, _| item_label.clone())
 445                })
 446                .map(|item| {
 447                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
 448                        || Some(ix) <= self.divider_index
 449                    {
 450                        let task_index = hit.candidate_id;
 451                        let delete_button = div().child(
 452                            IconButton::new("delete", IconName::Close)
 453                                .shape(IconButtonShape::Square)
 454                                .icon_color(Color::Muted)
 455                                .size(ButtonSize::None)
 456                                .icon_size(IconSize::XSmall)
 457                                .on_click(cx.listener(move |picker, _event, window, cx| {
 458                                    cx.stop_propagation();
 459                                    window.prevent_default();
 460
 461                                    picker.delegate.delete_previously_used(task_index, cx);
 462                                    picker.delegate.last_used_candidate_index = picker
 463                                        .delegate
 464                                        .last_used_candidate_index
 465                                        .unwrap_or(0)
 466                                        .checked_sub(1);
 467                                    picker.refresh(window, cx);
 468                                }))
 469                                .tooltip(|_, cx| {
 470                                    Tooltip::simple("Delete Previously Scheduled Task", cx)
 471                                }),
 472                        );
 473                        item.end_hover_slot(delete_button)
 474                    } else {
 475                        item
 476                    };
 477                    item
 478                })
 479                .toggle_state(selected)
 480                .child(highlighted_location.render(window, cx)),
 481        )
 482    }
 483
 484    fn confirm_completion(
 485        &mut self,
 486        _: String,
 487        _window: &mut Window,
 488        _: &mut Context<Picker<Self>>,
 489    ) -> Option<String> {
 490        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 491        let tasks = self.candidates.as_ref()?;
 492        let (_, task) = tasks.get(task_index)?;
 493        Some(task.resolved.as_ref()?.command_label.clone())
 494    }
 495
 496    fn confirm_input(
 497        &mut self,
 498        omit_history_entry: bool,
 499        _: &mut Window,
 500        cx: &mut Context<Picker<Self>>,
 501    ) {
 502        let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
 503            return;
 504        };
 505
 506        if let Some(TaskOverrides {
 507            reveal_target: Some(reveal_target),
 508        }) = self.task_overrides
 509        {
 510            if let Some(resolved_task) = &mut task.resolved {
 511                resolved_task.reveal_target = reveal_target;
 512            }
 513        }
 514        self.workspace
 515            .update(cx, |workspace, cx| {
 516                match task.task_type() {
 517                    TaskType::Script => schedule_resolved_task(
 518                        workspace,
 519                        task_source_kind,
 520                        task,
 521                        omit_history_entry,
 522                        cx,
 523                    ),
 524                    // todo(debugger): Should create a schedule_resolved_debug_task function
 525                    // This would allow users to access to debug history and other issues
 526                    TaskType::Debug(debug_args) => {
 527                        let Some(debug_config) = task.resolved_debug_adapter_config() else {
 528                            // todo(debugger) log an error, this should never happen
 529                            return;
 530                        };
 531
 532                        if debug_args.locator.is_some() {
 533                            schedule_resolved_task(
 534                                workspace,
 535                                task_source_kind,
 536                                task,
 537                                omit_history_entry,
 538                                cx,
 539                            );
 540                        } else {
 541                            workspace.project().update(cx, |project, cx| {
 542                                project
 543                                    .start_debug_session(debug_config, cx)
 544                                    .detach_and_log_err(cx);
 545                            });
 546                        }
 547                    }
 548                };
 549            })
 550            .ok();
 551        cx.emit(DismissEvent);
 552    }
 553
 554    fn separators_after_indices(&self) -> Vec<usize> {
 555        if let Some(i) = self.divider_index {
 556            vec![i]
 557        } else {
 558            Vec::new()
 559        }
 560    }
 561    fn render_footer(
 562        &self,
 563        window: &mut Window,
 564        cx: &mut Context<Picker<Self>>,
 565    ) -> Option<gpui::AnyElement> {
 566        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 567        let current_modifiers = window.modifiers();
 568        let left_button = if self
 569            .task_store
 570            .read(cx)
 571            .task_inventory()?
 572            .read(cx)
 573            .last_scheduled_task(None)
 574            .is_some()
 575        {
 576            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 577        } else {
 578            None
 579        };
 580        Some(
 581            h_flex()
 582                .w_full()
 583                .h_8()
 584                .p_2()
 585                .justify_between()
 586                .rounded_b_sm()
 587                .bg(cx.theme().colors().ghost_element_selected)
 588                .border_t_1()
 589                .border_color(cx.theme().colors().border_variant)
 590                .child(
 591                    left_button
 592                        .map(|(label, action)| {
 593                            let keybind = KeyBinding::for_action(&*action, window, cx);
 594
 595                            Button::new("edit-current-task", label)
 596                                .label_size(LabelSize::Small)
 597                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 598                                .on_click(move |_, window, cx| {
 599                                    window.dispatch_action(action.boxed_clone(), cx);
 600                                })
 601                                .into_any_element()
 602                        })
 603                        .unwrap_or_else(|| h_flex().into_any_element()),
 604                )
 605                .map(|this| {
 606                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 607                    {
 608                        let action = picker::ConfirmInput {
 609                            secondary: current_modifiers.secondary(),
 610                        }
 611                        .boxed_clone();
 612                        this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
 613                            let spawn_oneshot_label = if current_modifiers.secondary() {
 614                                "Spawn Oneshot Without History"
 615                            } else {
 616                                "Spawn Oneshot"
 617                            };
 618
 619                            Button::new("spawn-onehshot", spawn_oneshot_label)
 620                                .label_size(LabelSize::Small)
 621                                .key_binding(keybind)
 622                                .on_click(move |_, window, cx| {
 623                                    window.dispatch_action(action.boxed_clone(), cx)
 624                                })
 625                        }))
 626                    } else if current_modifiers.secondary() {
 627                        this.children(
 628                            KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
 629                                |keybind| {
 630                                    let label = if is_recent_selected {
 631                                        "Rerun Without History"
 632                                    } else {
 633                                        "Spawn Without History"
 634                                    };
 635                                    Button::new("spawn", label)
 636                                        .label_size(LabelSize::Small)
 637                                        .key_binding(keybind)
 638                                        .on_click(move |_, window, cx| {
 639                                            window.dispatch_action(
 640                                                menu::SecondaryConfirm.boxed_clone(),
 641                                                cx,
 642                                            )
 643                                        })
 644                                },
 645                            ),
 646                        )
 647                    } else {
 648                        this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
 649                            |keybind| {
 650                                let run_entry_label =
 651                                    if is_recent_selected { "Rerun" } else { "Spawn" };
 652
 653                                Button::new("spawn", run_entry_label)
 654                                    .label_size(LabelSize::Small)
 655                                    .key_binding(keybind)
 656                                    .on_click(|_, window, cx| {
 657                                        window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 658                                    })
 659                            },
 660                        ))
 661                    }
 662                })
 663                .into_any_element(),
 664        )
 665    }
 666}
 667
 668fn string_match_candidates<'a>(
 669    candidates: impl Iterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 670    task_modal_type: TaskModal,
 671) -> Vec<StringMatchCandidate> {
 672    candidates
 673        .enumerate()
 674        .filter(|(_, (_, candidate))| match candidate.task_type() {
 675            TaskType::Script => task_modal_type == TaskModal::ScriptModal,
 676            TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal,
 677        })
 678        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 679        .collect()
 680}
 681
 682#[cfg(test)]
 683mod tests {
 684    use std::{path::PathBuf, sync::Arc};
 685
 686    use editor::Editor;
 687    use gpui::{TestAppContext, VisualTestContext};
 688    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 689    use project::{ContextProviderWithTasks, FakeFs, Project};
 690    use serde_json::json;
 691    use task::TaskTemplates;
 692    use util::path;
 693    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 694
 695    use crate::{modal::Spawn, tests::init_test};
 696
 697    use super::*;
 698
 699    #[gpui::test]
 700    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 701        init_test(cx);
 702        let fs = FakeFs::new(cx.executor());
 703        fs.insert_tree(
 704            path!("/dir"),
 705            json!({
 706                ".zed": {
 707                    "tasks.json": r#"[
 708                        {
 709                            "label": "example task",
 710                            "command": "echo",
 711                            "args": ["4"]
 712                        },
 713                        {
 714                            "label": "another one",
 715                            "command": "echo",
 716                            "args": ["55"]
 717                        },
 718                    ]"#,
 719                },
 720                "a.ts": "a"
 721            }),
 722        )
 723        .await;
 724
 725        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 726        let (workspace, cx) =
 727            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 728
 729        let tasks_picker = open_spawn_tasks(&workspace, cx);
 730        assert_eq!(
 731            query(&tasks_picker, cx),
 732            "",
 733            "Initial query should be empty"
 734        );
 735        assert_eq!(
 736            task_names(&tasks_picker, cx),
 737            vec!["another one", "example task"],
 738            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 739        );
 740        drop(tasks_picker);
 741
 742        let _ = workspace
 743            .update_in(cx, |workspace, window, cx| {
 744                workspace.open_abs_path(
 745                    PathBuf::from(path!("/dir/a.ts")),
 746                    OpenOptions {
 747                        visible: Some(OpenVisible::All),
 748                        ..Default::default()
 749                    },
 750                    window,
 751                    cx,
 752                )
 753            })
 754            .await
 755            .unwrap();
 756        let tasks_picker = open_spawn_tasks(&workspace, cx);
 757        assert_eq!(
 758            task_names(&tasks_picker, cx),
 759            vec!["another one", "example task"],
 760            "Initial tasks should be listed in alphabetical order"
 761        );
 762
 763        let query_str = "tas";
 764        cx.simulate_input(query_str);
 765        assert_eq!(query(&tasks_picker, cx), query_str);
 766        assert_eq!(
 767            task_names(&tasks_picker, cx),
 768            vec!["example task"],
 769            "Only one task should match the query {query_str}"
 770        );
 771
 772        cx.dispatch_action(picker::ConfirmCompletion);
 773        assert_eq!(
 774            query(&tasks_picker, cx),
 775            "echo 4",
 776            "Query should be set to the selected task's command"
 777        );
 778        assert_eq!(
 779            task_names(&tasks_picker, cx),
 780            Vec::<String>::new(),
 781            "No task should be listed"
 782        );
 783        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 784
 785        let tasks_picker = open_spawn_tasks(&workspace, cx);
 786        assert_eq!(
 787            query(&tasks_picker, cx),
 788            "",
 789            "Query should be reset after confirming"
 790        );
 791        assert_eq!(
 792            task_names(&tasks_picker, cx),
 793            vec!["echo 4", "another one", "example task"],
 794            "New oneshot task should be listed first"
 795        );
 796
 797        let query_str = "echo 4";
 798        cx.simulate_input(query_str);
 799        assert_eq!(query(&tasks_picker, cx), query_str);
 800        assert_eq!(
 801            task_names(&tasks_picker, cx),
 802            vec!["echo 4"],
 803            "New oneshot should match custom command query"
 804        );
 805
 806        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 807        let tasks_picker = open_spawn_tasks(&workspace, cx);
 808        assert_eq!(
 809            query(&tasks_picker, cx),
 810            "",
 811            "Query should be reset after confirming"
 812        );
 813        assert_eq!(
 814            task_names(&tasks_picker, cx),
 815            vec![query_str, "another one", "example task"],
 816            "Last recently used one show task should be listed first"
 817        );
 818
 819        cx.dispatch_action(picker::ConfirmCompletion);
 820        assert_eq!(
 821            query(&tasks_picker, cx),
 822            query_str,
 823            "Query should be set to the custom task's name"
 824        );
 825        assert_eq!(
 826            task_names(&tasks_picker, cx),
 827            vec![query_str],
 828            "Only custom task should be listed"
 829        );
 830
 831        let query_str = "0";
 832        cx.simulate_input(query_str);
 833        assert_eq!(query(&tasks_picker, cx), "echo 40");
 834        assert_eq!(
 835            task_names(&tasks_picker, cx),
 836            Vec::<String>::new(),
 837            "New oneshot should not match any command query"
 838        );
 839
 840        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 841        let tasks_picker = open_spawn_tasks(&workspace, cx);
 842        assert_eq!(
 843            query(&tasks_picker, cx),
 844            "",
 845            "Query should be reset after confirming"
 846        );
 847        assert_eq!(
 848            task_names(&tasks_picker, cx),
 849            vec!["echo 4", "another one", "example task"],
 850            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 851        );
 852
 853        cx.dispatch_action(Spawn::ByName {
 854            task_name: "example task".to_string(),
 855            reveal_target: None,
 856        });
 857        let tasks_picker = workspace.update(cx, |workspace, cx| {
 858            workspace
 859                .active_modal::<TasksModal>(cx)
 860                .unwrap()
 861                .read(cx)
 862                .picker
 863                .clone()
 864        });
 865        assert_eq!(
 866            task_names(&tasks_picker, cx),
 867            vec!["echo 4", "another one", "example task"],
 868        );
 869    }
 870
 871    #[gpui::test]
 872    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 873        init_test(cx);
 874        let fs = FakeFs::new(cx.executor());
 875        fs.insert_tree(
 876            path!("/dir"),
 877            json!({
 878                ".zed": {
 879                    "tasks.json": r#"[
 880                        {
 881                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 882                            "command": "echo",
 883                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 884                        },
 885                        {
 886                            "label": "opened now: $ZED_WORKTREE_ROOT",
 887                            "command": "echo",
 888                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 889                        }
 890                    ]"#,
 891                },
 892                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 893                "file_with.odd_extension": "b",
 894            }),
 895        )
 896        .await;
 897
 898        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 899        let (workspace, cx) =
 900            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 901
 902        let tasks_picker = open_spawn_tasks(&workspace, cx);
 903        assert_eq!(
 904            task_names(&tasks_picker, cx),
 905            vec![concat!("opened now: ", path!("/dir")).to_string()],
 906            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 907        );
 908        tasks_picker.update(cx, |_, cx| {
 909            cx.emit(DismissEvent);
 910        });
 911        drop(tasks_picker);
 912        cx.executor().run_until_parked();
 913
 914        let _ = workspace
 915            .update_in(cx, |workspace, window, cx| {
 916                workspace.open_abs_path(
 917                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 918                    OpenOptions {
 919                        visible: Some(OpenVisible::All),
 920                        ..Default::default()
 921                    },
 922                    window,
 923                    cx,
 924                )
 925            })
 926            .await
 927            .unwrap();
 928        cx.executor().run_until_parked();
 929        let tasks_picker = open_spawn_tasks(&workspace, cx);
 930        assert_eq!(
 931            task_names(&tasks_picker, cx),
 932            vec![
 933                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 934                concat!("opened now: ", path!("/dir")).to_string(),
 935            ],
 936            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 937        );
 938        tasks_picker.update(cx, |_, cx| {
 939            cx.emit(DismissEvent);
 940        });
 941        drop(tasks_picker);
 942        cx.executor().run_until_parked();
 943
 944        let second_item = workspace
 945            .update_in(cx, |workspace, window, cx| {
 946                workspace.open_abs_path(
 947                    PathBuf::from(path!("/dir/file_without_extension")),
 948                    OpenOptions {
 949                        visible: Some(OpenVisible::All),
 950                        ..Default::default()
 951                    },
 952                    window,
 953                    cx,
 954                )
 955            })
 956            .await
 957            .unwrap();
 958
 959        let editor = cx
 960            .update(|_window, cx| second_item.act_as::<Editor>(cx))
 961            .unwrap();
 962        editor.update_in(cx, |editor, window, cx| {
 963            editor.change_selections(None, window, cx, |s| {
 964                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 965            })
 966        });
 967        cx.executor().run_until_parked();
 968        let tasks_picker = open_spawn_tasks(&workspace, cx);
 969        assert_eq!(
 970            task_names(&tasks_picker, cx),
 971            vec![
 972                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
 973                concat!("opened now: ", path!("/dir")).to_string(),
 974            ],
 975            "Opened buffer should fill the context, labels should be trimmed if long enough"
 976        );
 977        tasks_picker.update(cx, |_, cx| {
 978            cx.emit(DismissEvent);
 979        });
 980        drop(tasks_picker);
 981        cx.executor().run_until_parked();
 982    }
 983
 984    #[gpui::test]
 985    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 986        init_test(cx);
 987        let fs = FakeFs::new(cx.executor());
 988        fs.insert_tree(
 989            path!("/dir"),
 990            json!({
 991                "a1.ts": "// a1",
 992                "a2.ts": "// a2",
 993                "b.rs": "// b",
 994            }),
 995        )
 996        .await;
 997
 998        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 999        project.read_with(cx, |project, _| {
1000            let language_registry = project.languages();
1001            language_registry.add(Arc::new(
1002                Language::new(
1003                    LanguageConfig {
1004                        name: "TypeScript".into(),
1005                        matcher: LanguageMatcher {
1006                            path_suffixes: vec!["ts".to_string()],
1007                            ..LanguageMatcher::default()
1008                        },
1009                        ..LanguageConfig::default()
1010                    },
1011                    None,
1012                )
1013                .with_context_provider(Some(Arc::new(
1014                    ContextProviderWithTasks::new(TaskTemplates(vec![
1015                        TaskTemplate {
1016                            label: "Task without variables".to_string(),
1017                            command: "npm run clean".to_string(),
1018                            ..TaskTemplate::default()
1019                        },
1020                        TaskTemplate {
1021                            label: "TypeScript task from file $ZED_FILE".to_string(),
1022                            command: "npm run build".to_string(),
1023                            ..TaskTemplate::default()
1024                        },
1025                        TaskTemplate {
1026                            label: "Another task from file $ZED_FILE".to_string(),
1027                            command: "npm run lint".to_string(),
1028                            ..TaskTemplate::default()
1029                        },
1030                    ])),
1031                ))),
1032            ));
1033            language_registry.add(Arc::new(
1034                Language::new(
1035                    LanguageConfig {
1036                        name: "Rust".into(),
1037                        matcher: LanguageMatcher {
1038                            path_suffixes: vec!["rs".to_string()],
1039                            ..LanguageMatcher::default()
1040                        },
1041                        ..LanguageConfig::default()
1042                    },
1043                    None,
1044                )
1045                .with_context_provider(Some(Arc::new(
1046                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1047                        label: "Rust task".to_string(),
1048                        command: "cargo check".into(),
1049                        ..TaskTemplate::default()
1050                    }])),
1051                ))),
1052            ));
1053        });
1054        let (workspace, cx) =
1055            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1056
1057        let _ts_file_1 = workspace
1058            .update_in(cx, |workspace, window, cx| {
1059                workspace.open_abs_path(
1060                    PathBuf::from(path!("/dir/a1.ts")),
1061                    OpenOptions {
1062                        visible: Some(OpenVisible::All),
1063                        ..Default::default()
1064                    },
1065                    window,
1066                    cx,
1067                )
1068            })
1069            .await
1070            .unwrap();
1071        let tasks_picker = open_spawn_tasks(&workspace, cx);
1072        assert_eq!(
1073            task_names(&tasks_picker, cx),
1074            vec![
1075                concat!("Another task from file ", path!("/dir/a1.ts")),
1076                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1077                "Task without variables",
1078            ],
1079            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1080        );
1081
1082        emulate_task_schedule(
1083            tasks_picker,
1084            &project,
1085            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1086            cx,
1087        );
1088
1089        let tasks_picker = open_spawn_tasks(&workspace, cx);
1090        assert_eq!(
1091            task_names(&tasks_picker, cx),
1092            vec![
1093                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1094                concat!("Another task from file ", path!("/dir/a1.ts")),
1095                "Task without variables",
1096            ],
1097            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1098            Tasks with the same labels and context are deduplicated."
1099        );
1100        tasks_picker.update(cx, |_, cx| {
1101            cx.emit(DismissEvent);
1102        });
1103        drop(tasks_picker);
1104        cx.executor().run_until_parked();
1105
1106        let _ts_file_2 = workspace
1107            .update_in(cx, |workspace, window, cx| {
1108                workspace.open_abs_path(
1109                    PathBuf::from(path!("/dir/a2.ts")),
1110                    OpenOptions {
1111                        visible: Some(OpenVisible::All),
1112                        ..Default::default()
1113                    },
1114                    window,
1115                    cx,
1116                )
1117            })
1118            .await
1119            .unwrap();
1120        let tasks_picker = open_spawn_tasks(&workspace, cx);
1121        assert_eq!(
1122            task_names(&tasks_picker, cx),
1123            vec![
1124                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1125                concat!("Another task from file ", path!("/dir/a2.ts")),
1126                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1127                "Task without variables",
1128            ],
1129            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1130        );
1131        tasks_picker.update(cx, |_, cx| {
1132            cx.emit(DismissEvent);
1133        });
1134        drop(tasks_picker);
1135        cx.executor().run_until_parked();
1136
1137        let _rs_file = workspace
1138            .update_in(cx, |workspace, window, cx| {
1139                workspace.open_abs_path(
1140                    PathBuf::from(path!("/dir/b.rs")),
1141                    OpenOptions {
1142                        visible: Some(OpenVisible::All),
1143                        ..Default::default()
1144                    },
1145                    window,
1146                    cx,
1147                )
1148            })
1149            .await
1150            .unwrap();
1151        let tasks_picker = open_spawn_tasks(&workspace, cx);
1152        assert_eq!(
1153            task_names(&tasks_picker, cx),
1154            vec!["Rust task"],
1155            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1156        );
1157
1158        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1159        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1160        let _ts_file_2 = workspace
1161            .update_in(cx, |workspace, window, cx| {
1162                workspace.open_abs_path(
1163                    PathBuf::from(path!("/dir/a2.ts")),
1164                    OpenOptions {
1165                        visible: Some(OpenVisible::All),
1166                        ..Default::default()
1167                    },
1168                    window,
1169                    cx,
1170                )
1171            })
1172            .await
1173            .unwrap();
1174        let tasks_picker = open_spawn_tasks(&workspace, cx);
1175        assert_eq!(
1176            task_names(&tasks_picker, cx),
1177            vec![
1178                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1179                concat!("Another task from file ", path!("/dir/a2.ts")),
1180                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1181                "Task without variables",
1182            ],
1183            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1184            same TS spawn history should be restored"
1185        );
1186    }
1187
1188    fn emulate_task_schedule(
1189        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1190        project: &Entity<Project>,
1191        scheduled_task_label: &str,
1192        cx: &mut VisualTestContext,
1193    ) {
1194        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1195            tasks_picker
1196                .delegate
1197                .candidates
1198                .iter()
1199                .flatten()
1200                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1201                .cloned()
1202                .unwrap()
1203        });
1204        project.update(cx, |project, cx| {
1205            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1206                task_inventory.update(cx, |inventory, _| {
1207                    let (kind, task) = scheduled_task;
1208                    inventory.task_scheduled(kind, task);
1209                });
1210            }
1211        });
1212        tasks_picker.update(cx, |_, cx| {
1213            cx.emit(DismissEvent);
1214        });
1215        drop(tasks_picker);
1216        cx.executor().run_until_parked()
1217    }
1218
1219    fn open_spawn_tasks(
1220        workspace: &Entity<Workspace>,
1221        cx: &mut VisualTestContext,
1222    ) -> Entity<Picker<TasksModalDelegate>> {
1223        cx.dispatch_action(Spawn::modal());
1224        workspace.update(cx, |workspace, cx| {
1225            workspace
1226                .active_modal::<TasksModal>(cx)
1227                .expect("no task modal after `Spawn` action was dispatched")
1228                .read(cx)
1229                .picker
1230                .clone()
1231        })
1232    }
1233
1234    fn query(
1235        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1236        cx: &mut VisualTestContext,
1237    ) -> String {
1238        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1239    }
1240
1241    fn task_names(
1242        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1243        cx: &mut VisualTestContext,
1244    ) -> Vec<String> {
1245        spawn_tasks.update(cx, |spawn_tasks, _| {
1246            spawn_tasks
1247                .delegate
1248                .matches
1249                .iter()
1250                .map(|hit| hit.string.clone())
1251                .collect::<Vec<_>>()
1252        })
1253    }
1254}