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