modal.rs

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