modal.rs

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