modal.rs

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