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, move |picker, mut cx| async move {
 221            let Some(candidates) = picker
 222                .update(&mut cx, |picker, cx| {
 223                    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) =
 237                                task_inventory.read(cx).used_and_current_resolved_tasks(
 238                                    &picker.delegate.task_contexts,
 239                                    cx,
 240                                );
 241                            picker.delegate.last_used_candidate_index = if used.is_empty() {
 242                                None
 243                            } else {
 244                                Some(used.len() - 1)
 245                            };
 246
 247                            let mut new_candidates = used;
 248                            new_candidates.extend(current);
 249                            let match_candidates =
 250                                string_match_candidates(new_candidates.iter(), task_type);
 251                            let _ = picker.delegate.candidates.insert(new_candidates);
 252                            match_candidates
 253                        }
 254                    }
 255                })
 256                .ok()
 257            else {
 258                return;
 259            };
 260            let matches = fuzzy::match_strings(
 261                &candidates,
 262                &query,
 263                true,
 264                1000,
 265                &Default::default(),
 266                cx.background_executor().clone(),
 267            )
 268            .await;
 269            picker
 270                .update(&mut cx, |picker, _| {
 271                    let delegate = &mut picker.delegate;
 272                    delegate.matches = matches;
 273                    if let Some(index) = delegate.last_used_candidate_index {
 274                        delegate.matches.sort_by_key(|m| m.candidate_id > index);
 275                    }
 276
 277                    delegate.prompt = query;
 278                    delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
 279                        let index = delegate
 280                            .matches
 281                            .partition_point(|matching_task| matching_task.candidate_id <= index);
 282                        Some(index).and_then(|index| (index != 0).then(|| index - 1))
 283                    });
 284
 285                    if delegate.matches.is_empty() {
 286                        delegate.selected_index = 0;
 287                    } else {
 288                        delegate.selected_index =
 289                            delegate.selected_index.min(delegate.matches.len() - 1);
 290                    }
 291                })
 292                .log_err();
 293        })
 294    }
 295
 296    fn confirm(
 297        &mut self,
 298        omit_history_entry: bool,
 299        window: &mut Window,
 300        cx: &mut Context<picker::Picker<Self>>,
 301    ) {
 302        let current_match_index = self.selected_index();
 303        let task = self
 304            .matches
 305            .get(current_match_index)
 306            .and_then(|current_match| {
 307                let ix = current_match.candidate_id;
 308                self.candidates
 309                    .as_ref()
 310                    .map(|candidates| candidates[ix].clone())
 311            });
 312        let Some((task_source_kind, mut task)) = task else {
 313            return;
 314        };
 315        if let Some(TaskOverrides {
 316            reveal_target: Some(reveal_target),
 317        }) = &self.task_overrides
 318        {
 319            if let Some(resolved_task) = &mut task.resolved {
 320                resolved_task.reveal_target = *reveal_target;
 321            }
 322        }
 323
 324        self.workspace
 325            .update(cx, |workspace, cx| {
 326                match task.task_type() {
 327                    TaskType::Script => schedule_resolved_task(
 328                        workspace,
 329                        task_source_kind,
 330                        task,
 331                        omit_history_entry,
 332                        cx,
 333                    ),
 334                    TaskType::Debug(_) => {
 335                        let Some(config) = task.resolved_debug_adapter_config() else {
 336                            return;
 337                        };
 338                        let project = workspace.project().clone();
 339
 340                        match &config.request {
 341                            DebugRequestType::Attach(attach_config)
 342                                if attach_config.process_id.is_none() =>
 343                            {
 344                                workspace.toggle_modal(window, cx, |window, cx| {
 345                                    debugger_ui::attach_modal::AttachModal::new(
 346                                        project,
 347                                        config.clone(),
 348                                        window,
 349                                        cx,
 350                                    )
 351                                });
 352                            }
 353                            _ => {
 354                                project.update(cx, |project, cx| {
 355                                    project
 356                                        .start_debug_session(config, cx)
 357                                        .detach_and_log_err(cx);
 358                                });
 359                            }
 360                        }
 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: Should create a schedule_resolved_debug_task function
 525                    // This would allow users to access to debug history and other issues
 526                    TaskType::Debug(_) => workspace.project().update(cx, |project, cx| {
 527                        project
 528                            .start_debug_session(task.resolved_debug_adapter_config().unwrap(), cx)
 529                            .detach_and_log_err(cx);
 530                    }),
 531                };
 532            })
 533            .ok();
 534        cx.emit(DismissEvent);
 535    }
 536
 537    fn separators_after_indices(&self) -> Vec<usize> {
 538        if let Some(i) = self.divider_index {
 539            vec![i]
 540        } else {
 541            Vec::new()
 542        }
 543    }
 544    fn render_footer(
 545        &self,
 546        window: &mut Window,
 547        cx: &mut Context<Picker<Self>>,
 548    ) -> Option<gpui::AnyElement> {
 549        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 550        let current_modifiers = window.modifiers();
 551        let left_button = if self
 552            .task_store
 553            .read(cx)
 554            .task_inventory()?
 555            .read(cx)
 556            .last_scheduled_task(None)
 557            .is_some()
 558        {
 559            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 560        } else {
 561            None
 562        };
 563        Some(
 564            h_flex()
 565                .w_full()
 566                .h_8()
 567                .p_2()
 568                .justify_between()
 569                .rounded_b_sm()
 570                .bg(cx.theme().colors().ghost_element_selected)
 571                .border_t_1()
 572                .border_color(cx.theme().colors().border_variant)
 573                .child(
 574                    left_button
 575                        .map(|(label, action)| {
 576                            let keybind = KeyBinding::for_action(&*action, window, cx);
 577
 578                            Button::new("edit-current-task", label)
 579                                .label_size(LabelSize::Small)
 580                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 581                                .on_click(move |_, window, cx| {
 582                                    window.dispatch_action(action.boxed_clone(), cx);
 583                                })
 584                                .into_any_element()
 585                        })
 586                        .unwrap_or_else(|| h_flex().into_any_element()),
 587                )
 588                .map(|this| {
 589                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 590                    {
 591                        let action = picker::ConfirmInput {
 592                            secondary: current_modifiers.secondary(),
 593                        }
 594                        .boxed_clone();
 595                        this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
 596                            let spawn_oneshot_label = if current_modifiers.secondary() {
 597                                "Spawn Oneshot Without History"
 598                            } else {
 599                                "Spawn Oneshot"
 600                            };
 601
 602                            Button::new("spawn-onehshot", spawn_oneshot_label)
 603                                .label_size(LabelSize::Small)
 604                                .key_binding(keybind)
 605                                .on_click(move |_, window, cx| {
 606                                    window.dispatch_action(action.boxed_clone(), cx)
 607                                })
 608                        }))
 609                    } else if current_modifiers.secondary() {
 610                        this.children(
 611                            KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
 612                                |keybind| {
 613                                    let label = if is_recent_selected {
 614                                        "Rerun Without History"
 615                                    } else {
 616                                        "Spawn Without History"
 617                                    };
 618                                    Button::new("spawn", label)
 619                                        .label_size(LabelSize::Small)
 620                                        .key_binding(keybind)
 621                                        .on_click(move |_, window, cx| {
 622                                            window.dispatch_action(
 623                                                menu::SecondaryConfirm.boxed_clone(),
 624                                                cx,
 625                                            )
 626                                        })
 627                                },
 628                            ),
 629                        )
 630                    } else {
 631                        this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
 632                            |keybind| {
 633                                let run_entry_label =
 634                                    if is_recent_selected { "Rerun" } else { "Spawn" };
 635
 636                                Button::new("spawn", run_entry_label)
 637                                    .label_size(LabelSize::Small)
 638                                    .key_binding(keybind)
 639                                    .on_click(|_, window, cx| {
 640                                        window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 641                                    })
 642                            },
 643                        ))
 644                    }
 645                })
 646                .into_any_element(),
 647        )
 648    }
 649}
 650
 651fn string_match_candidates<'a>(
 652    candidates: impl Iterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 653    task_modal_type: TaskModal,
 654) -> Vec<StringMatchCandidate> {
 655    candidates
 656        .enumerate()
 657        .filter(|(_, (_, candidate))| match candidate.task_type() {
 658            TaskType::Script => task_modal_type == TaskModal::ScriptModal,
 659            TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal,
 660        })
 661        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 662        .collect()
 663}
 664
 665#[cfg(test)]
 666mod tests {
 667    use std::{path::PathBuf, sync::Arc};
 668
 669    use editor::Editor;
 670    use gpui::{TestAppContext, VisualTestContext};
 671    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 672    use project::{ContextProviderWithTasks, FakeFs, Project};
 673    use serde_json::json;
 674    use task::TaskTemplates;
 675    use util::path;
 676    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 677
 678    use crate::{modal::Spawn, tests::init_test};
 679
 680    use super::*;
 681
 682    #[gpui::test]
 683    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 684        init_test(cx);
 685        let fs = FakeFs::new(cx.executor());
 686        fs.insert_tree(
 687            path!("/dir"),
 688            json!({
 689                ".zed": {
 690                    "tasks.json": r#"[
 691                        {
 692                            "label": "example task",
 693                            "command": "echo",
 694                            "args": ["4"]
 695                        },
 696                        {
 697                            "label": "another one",
 698                            "command": "echo",
 699                            "args": ["55"]
 700                        },
 701                    ]"#,
 702                },
 703                "a.ts": "a"
 704            }),
 705        )
 706        .await;
 707
 708        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 709        let (workspace, cx) =
 710            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 711
 712        let tasks_picker = open_spawn_tasks(&workspace, cx);
 713        assert_eq!(
 714            query(&tasks_picker, cx),
 715            "",
 716            "Initial query should be empty"
 717        );
 718        assert_eq!(
 719            task_names(&tasks_picker, cx),
 720            vec!["another one", "example task"],
 721            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 722        );
 723        drop(tasks_picker);
 724
 725        let _ = workspace
 726            .update_in(cx, |workspace, window, cx| {
 727                workspace.open_abs_path(
 728                    PathBuf::from(path!("/dir/a.ts")),
 729                    OpenOptions {
 730                        visible: Some(OpenVisible::All),
 731                        ..Default::default()
 732                    },
 733                    window,
 734                    cx,
 735                )
 736            })
 737            .await
 738            .unwrap();
 739        let tasks_picker = open_spawn_tasks(&workspace, cx);
 740        assert_eq!(
 741            task_names(&tasks_picker, cx),
 742            vec!["another one", "example task"],
 743            "Initial tasks should be listed in alphabetical order"
 744        );
 745
 746        let query_str = "tas";
 747        cx.simulate_input(query_str);
 748        assert_eq!(query(&tasks_picker, cx), query_str);
 749        assert_eq!(
 750            task_names(&tasks_picker, cx),
 751            vec!["example task"],
 752            "Only one task should match the query {query_str}"
 753        );
 754
 755        cx.dispatch_action(picker::ConfirmCompletion);
 756        assert_eq!(
 757            query(&tasks_picker, cx),
 758            "echo 4",
 759            "Query should be set to the selected task's command"
 760        );
 761        assert_eq!(
 762            task_names(&tasks_picker, cx),
 763            Vec::<String>::new(),
 764            "No task should be listed"
 765        );
 766        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 767
 768        let tasks_picker = open_spawn_tasks(&workspace, cx);
 769        assert_eq!(
 770            query(&tasks_picker, cx),
 771            "",
 772            "Query should be reset after confirming"
 773        );
 774        assert_eq!(
 775            task_names(&tasks_picker, cx),
 776            vec!["echo 4", "another one", "example task"],
 777            "New oneshot task should be listed first"
 778        );
 779
 780        let query_str = "echo 4";
 781        cx.simulate_input(query_str);
 782        assert_eq!(query(&tasks_picker, cx), query_str);
 783        assert_eq!(
 784            task_names(&tasks_picker, cx),
 785            vec!["echo 4"],
 786            "New oneshot should match custom command query"
 787        );
 788
 789        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 790        let tasks_picker = open_spawn_tasks(&workspace, cx);
 791        assert_eq!(
 792            query(&tasks_picker, cx),
 793            "",
 794            "Query should be reset after confirming"
 795        );
 796        assert_eq!(
 797            task_names(&tasks_picker, cx),
 798            vec![query_str, "another one", "example task"],
 799            "Last recently used one show task should be listed first"
 800        );
 801
 802        cx.dispatch_action(picker::ConfirmCompletion);
 803        assert_eq!(
 804            query(&tasks_picker, cx),
 805            query_str,
 806            "Query should be set to the custom task's name"
 807        );
 808        assert_eq!(
 809            task_names(&tasks_picker, cx),
 810            vec![query_str],
 811            "Only custom task should be listed"
 812        );
 813
 814        let query_str = "0";
 815        cx.simulate_input(query_str);
 816        assert_eq!(query(&tasks_picker, cx), "echo 40");
 817        assert_eq!(
 818            task_names(&tasks_picker, cx),
 819            Vec::<String>::new(),
 820            "New oneshot should not match any command query"
 821        );
 822
 823        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 824        let tasks_picker = open_spawn_tasks(&workspace, cx);
 825        assert_eq!(
 826            query(&tasks_picker, cx),
 827            "",
 828            "Query should be reset after confirming"
 829        );
 830        assert_eq!(
 831            task_names(&tasks_picker, cx),
 832            vec!["echo 4", "another one", "example task"],
 833            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 834        );
 835
 836        cx.dispatch_action(Spawn::ByName {
 837            task_name: "example task".to_string(),
 838            reveal_target: None,
 839        });
 840        let tasks_picker = workspace.update(cx, |workspace, cx| {
 841            workspace
 842                .active_modal::<TasksModal>(cx)
 843                .unwrap()
 844                .read(cx)
 845                .picker
 846                .clone()
 847        });
 848        assert_eq!(
 849            task_names(&tasks_picker, cx),
 850            vec!["echo 4", "another one", "example task"],
 851        );
 852    }
 853
 854    #[gpui::test]
 855    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 856        init_test(cx);
 857        let fs = FakeFs::new(cx.executor());
 858        fs.insert_tree(
 859            path!("/dir"),
 860            json!({
 861                ".zed": {
 862                    "tasks.json": r#"[
 863                        {
 864                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 865                            "command": "echo",
 866                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 867                        },
 868                        {
 869                            "label": "opened now: $ZED_WORKTREE_ROOT",
 870                            "command": "echo",
 871                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 872                        }
 873                    ]"#,
 874                },
 875                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 876                "file_with.odd_extension": "b",
 877            }),
 878        )
 879        .await;
 880
 881        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 882        let (workspace, cx) =
 883            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 884
 885        let tasks_picker = open_spawn_tasks(&workspace, cx);
 886        assert_eq!(
 887            task_names(&tasks_picker, cx),
 888            vec![concat!("opened now: ", path!("/dir")).to_string()],
 889            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 890        );
 891        tasks_picker.update(cx, |_, cx| {
 892            cx.emit(DismissEvent);
 893        });
 894        drop(tasks_picker);
 895        cx.executor().run_until_parked();
 896
 897        let _ = workspace
 898            .update_in(cx, |workspace, window, cx| {
 899                workspace.open_abs_path(
 900                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 901                    OpenOptions {
 902                        visible: Some(OpenVisible::All),
 903                        ..Default::default()
 904                    },
 905                    window,
 906                    cx,
 907                )
 908            })
 909            .await
 910            .unwrap();
 911        cx.executor().run_until_parked();
 912        let tasks_picker = open_spawn_tasks(&workspace, cx);
 913        assert_eq!(
 914            task_names(&tasks_picker, cx),
 915            vec![
 916                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 917                concat!("opened now: ", path!("/dir")).to_string(),
 918            ],
 919            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 920        );
 921        tasks_picker.update(cx, |_, cx| {
 922            cx.emit(DismissEvent);
 923        });
 924        drop(tasks_picker);
 925        cx.executor().run_until_parked();
 926
 927        let second_item = workspace
 928            .update_in(cx, |workspace, window, cx| {
 929                workspace.open_abs_path(
 930                    PathBuf::from(path!("/dir/file_without_extension")),
 931                    OpenOptions {
 932                        visible: Some(OpenVisible::All),
 933                        ..Default::default()
 934                    },
 935                    window,
 936                    cx,
 937                )
 938            })
 939            .await
 940            .unwrap();
 941
 942        let editor = cx
 943            .update(|_window, cx| second_item.act_as::<Editor>(cx))
 944            .unwrap();
 945        editor.update_in(cx, |editor, window, cx| {
 946            editor.change_selections(None, window, cx, |s| {
 947                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 948            })
 949        });
 950        cx.executor().run_until_parked();
 951        let tasks_picker = open_spawn_tasks(&workspace, cx);
 952        assert_eq!(
 953            task_names(&tasks_picker, cx),
 954            vec![
 955                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
 956                concat!("opened now: ", path!("/dir")).to_string(),
 957            ],
 958            "Opened buffer should fill the context, labels should be trimmed if long enough"
 959        );
 960        tasks_picker.update(cx, |_, cx| {
 961            cx.emit(DismissEvent);
 962        });
 963        drop(tasks_picker);
 964        cx.executor().run_until_parked();
 965    }
 966
 967    #[gpui::test]
 968    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 969        init_test(cx);
 970        let fs = FakeFs::new(cx.executor());
 971        fs.insert_tree(
 972            path!("/dir"),
 973            json!({
 974                "a1.ts": "// a1",
 975                "a2.ts": "// a2",
 976                "b.rs": "// b",
 977            }),
 978        )
 979        .await;
 980
 981        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 982        project.read_with(cx, |project, _| {
 983            let language_registry = project.languages();
 984            language_registry.add(Arc::new(
 985                Language::new(
 986                    LanguageConfig {
 987                        name: "TypeScript".into(),
 988                        matcher: LanguageMatcher {
 989                            path_suffixes: vec!["ts".to_string()],
 990                            ..LanguageMatcher::default()
 991                        },
 992                        ..LanguageConfig::default()
 993                    },
 994                    None,
 995                )
 996                .with_context_provider(Some(Arc::new(
 997                    ContextProviderWithTasks::new(TaskTemplates(vec![
 998                        TaskTemplate {
 999                            label: "Task without variables".to_string(),
1000                            command: "npm run clean".to_string(),
1001                            ..TaskTemplate::default()
1002                        },
1003                        TaskTemplate {
1004                            label: "TypeScript task from file $ZED_FILE".to_string(),
1005                            command: "npm run build".to_string(),
1006                            ..TaskTemplate::default()
1007                        },
1008                        TaskTemplate {
1009                            label: "Another task from file $ZED_FILE".to_string(),
1010                            command: "npm run lint".to_string(),
1011                            ..TaskTemplate::default()
1012                        },
1013                    ])),
1014                ))),
1015            ));
1016            language_registry.add(Arc::new(
1017                Language::new(
1018                    LanguageConfig {
1019                        name: "Rust".into(),
1020                        matcher: LanguageMatcher {
1021                            path_suffixes: vec!["rs".to_string()],
1022                            ..LanguageMatcher::default()
1023                        },
1024                        ..LanguageConfig::default()
1025                    },
1026                    None,
1027                )
1028                .with_context_provider(Some(Arc::new(
1029                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1030                        label: "Rust task".to_string(),
1031                        command: "cargo check".into(),
1032                        ..TaskTemplate::default()
1033                    }])),
1034                ))),
1035            ));
1036        });
1037        let (workspace, cx) =
1038            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1039
1040        let _ts_file_1 = workspace
1041            .update_in(cx, |workspace, window, cx| {
1042                workspace.open_abs_path(
1043                    PathBuf::from(path!("/dir/a1.ts")),
1044                    OpenOptions {
1045                        visible: Some(OpenVisible::All),
1046                        ..Default::default()
1047                    },
1048                    window,
1049                    cx,
1050                )
1051            })
1052            .await
1053            .unwrap();
1054        let tasks_picker = open_spawn_tasks(&workspace, cx);
1055        assert_eq!(
1056            task_names(&tasks_picker, cx),
1057            vec![
1058                concat!("Another task from file ", path!("/dir/a1.ts")),
1059                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1060                "Task without variables",
1061            ],
1062            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1063        );
1064
1065        emulate_task_schedule(
1066            tasks_picker,
1067            &project,
1068            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1069            cx,
1070        );
1071
1072        let tasks_picker = open_spawn_tasks(&workspace, cx);
1073        assert_eq!(
1074            task_names(&tasks_picker, cx),
1075            vec![
1076                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1077                concat!("Another task from file ", path!("/dir/a1.ts")),
1078                "Task without variables",
1079            ],
1080            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1081            Tasks with the same labels and context are deduplicated."
1082        );
1083        tasks_picker.update(cx, |_, cx| {
1084            cx.emit(DismissEvent);
1085        });
1086        drop(tasks_picker);
1087        cx.executor().run_until_parked();
1088
1089        let _ts_file_2 = workspace
1090            .update_in(cx, |workspace, window, cx| {
1091                workspace.open_abs_path(
1092                    PathBuf::from(path!("/dir/a2.ts")),
1093                    OpenOptions {
1094                        visible: Some(OpenVisible::All),
1095                        ..Default::default()
1096                    },
1097                    window,
1098                    cx,
1099                )
1100            })
1101            .await
1102            .unwrap();
1103        let tasks_picker = open_spawn_tasks(&workspace, cx);
1104        assert_eq!(
1105            task_names(&tasks_picker, cx),
1106            vec![
1107                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1108                concat!("Another task from file ", path!("/dir/a2.ts")),
1109                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1110                "Task without variables",
1111            ],
1112            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1113        );
1114        tasks_picker.update(cx, |_, cx| {
1115            cx.emit(DismissEvent);
1116        });
1117        drop(tasks_picker);
1118        cx.executor().run_until_parked();
1119
1120        let _rs_file = workspace
1121            .update_in(cx, |workspace, window, cx| {
1122                workspace.open_abs_path(
1123                    PathBuf::from(path!("/dir/b.rs")),
1124                    OpenOptions {
1125                        visible: Some(OpenVisible::All),
1126                        ..Default::default()
1127                    },
1128                    window,
1129                    cx,
1130                )
1131            })
1132            .await
1133            .unwrap();
1134        let tasks_picker = open_spawn_tasks(&workspace, cx);
1135        assert_eq!(
1136            task_names(&tasks_picker, cx),
1137            vec!["Rust task"],
1138            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1139        );
1140
1141        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1142        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1143        let _ts_file_2 = workspace
1144            .update_in(cx, |workspace, window, cx| {
1145                workspace.open_abs_path(
1146                    PathBuf::from(path!("/dir/a2.ts")),
1147                    OpenOptions {
1148                        visible: Some(OpenVisible::All),
1149                        ..Default::default()
1150                    },
1151                    window,
1152                    cx,
1153                )
1154            })
1155            .await
1156            .unwrap();
1157        let tasks_picker = open_spawn_tasks(&workspace, cx);
1158        assert_eq!(
1159            task_names(&tasks_picker, cx),
1160            vec![
1161                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1162                concat!("Another task from file ", path!("/dir/a2.ts")),
1163                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1164                "Task without variables",
1165            ],
1166            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1167            same TS spawn history should be restored"
1168        );
1169    }
1170
1171    fn emulate_task_schedule(
1172        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1173        project: &Entity<Project>,
1174        scheduled_task_label: &str,
1175        cx: &mut VisualTestContext,
1176    ) {
1177        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1178            tasks_picker
1179                .delegate
1180                .candidates
1181                .iter()
1182                .flatten()
1183                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1184                .cloned()
1185                .unwrap()
1186        });
1187        project.update(cx, |project, cx| {
1188            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1189                task_inventory.update(cx, |inventory, _| {
1190                    let (kind, task) = scheduled_task;
1191                    inventory.task_scheduled(kind, task);
1192                });
1193            }
1194        });
1195        tasks_picker.update(cx, |_, cx| {
1196            cx.emit(DismissEvent);
1197        });
1198        drop(tasks_picker);
1199        cx.executor().run_until_parked()
1200    }
1201
1202    fn open_spawn_tasks(
1203        workspace: &Entity<Workspace>,
1204        cx: &mut VisualTestContext,
1205    ) -> Entity<Picker<TasksModalDelegate>> {
1206        cx.dispatch_action(Spawn::modal());
1207        workspace.update(cx, |workspace, cx| {
1208            workspace
1209                .active_modal::<TasksModal>(cx)
1210                .expect("no task modal after `Spawn` action was dispatched")
1211                .read(cx)
1212                .picker
1213                .clone()
1214        })
1215    }
1216
1217    fn query(
1218        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1219        cx: &mut VisualTestContext,
1220    ) -> String {
1221        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1222    }
1223
1224    fn task_names(
1225        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1226        cx: &mut VisualTestContext,
1227    ) -> Vec<String> {
1228        spawn_tasks.update(cx, |spawn_tasks, _| {
1229            spawn_tasks
1230                .delegate
1231                .matches
1232                .iter()
1233                .map(|hit| hit.string.clone())
1234                .collect::<Vec<_>>()
1235        })
1236    }
1237}