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