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.file_name().is_some_and(|name| name == ".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.file_name().is_some_and(|name| name == ".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.get(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 =
 452            if display_label != &template.label || source_kind == &TaskSourceKind::UserInput {
 453                resolved_task.resolved_label.clone()
 454            } else {
 455                String::new()
 456            };
 457
 458        if resolved_task.resolved.command_label != resolved_task.resolved_label {
 459            if !tooltip_label_text.trim().is_empty() {
 460                tooltip_label_text.push('\n');
 461            }
 462            tooltip_label_text.push_str(&resolved_task.resolved.command_label);
 463        }
 464
 465        if !template.tags.is_empty() {
 466            tooltip_label_text.push('\n');
 467            tooltip_label_text.push_str(
 468                template
 469                    .tags
 470                    .iter()
 471                    .map(|tag| format!("\n#{}", tag))
 472                    .collect::<Vec<_>>()
 473                    .join("")
 474                    .as_str(),
 475            );
 476        }
 477        let tooltip_label = if tooltip_label_text.trim().is_empty() {
 478            None
 479        } else {
 480            Some(Tooltip::simple(tooltip_label_text, cx))
 481        };
 482
 483        let highlighted_location = HighlightedMatch {
 484            text: hit.string.clone(),
 485            highlight_positions: hit.positions.clone(),
 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, cx);
 669
 670                            Button::new("edit-current-task", label)
 671                                .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.child({
 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(KeyBinding::for_action(&*action, cx))
 695                                .on_click(move |_, window, cx| {
 696                                    window.dispatch_action(action.boxed_clone(), cx)
 697                                })
 698                        })
 699                    } else if current_modifiers.secondary() {
 700                        this.child({
 701                            let label = if is_recent_selected {
 702                                "Rerun Without History"
 703                            } else {
 704                                "Spawn Without History"
 705                            };
 706                            Button::new("spawn", label)
 707                                .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
 708                                .on_click(move |_, window, cx| {
 709                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
 710                                })
 711                        })
 712                    } else {
 713                        this.child({
 714                            let run_entry_label =
 715                                if is_recent_selected { "Rerun" } else { "Spawn" };
 716
 717                            Button::new("spawn", run_entry_label)
 718                                .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
 719                                .on_click(|_, window, cx| {
 720                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 721                                })
 722                        })
 723                    }
 724                })
 725                .into_any_element(),
 726        )
 727    }
 728}
 729
 730fn string_match_candidates<'a>(
 731    candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 732) -> Vec<StringMatchCandidate> {
 733    candidates
 734        .into_iter()
 735        .enumerate()
 736        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 737        .collect()
 738}
 739
 740#[cfg(test)]
 741mod tests {
 742    use std::{path::PathBuf, sync::Arc};
 743
 744    use editor::{Editor, SelectionEffects};
 745    use gpui::{TestAppContext, VisualTestContext};
 746    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 747    use project::{ContextProviderWithTasks, FakeFs, Project};
 748    use serde_json::json;
 749    use task::TaskTemplates;
 750    use util::path;
 751    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 752
 753    use crate::{modal::Spawn, tests::init_test};
 754
 755    use super::*;
 756
 757    #[gpui::test]
 758    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 759        init_test(cx);
 760        let fs = FakeFs::new(cx.executor());
 761        fs.insert_tree(
 762            path!("/dir"),
 763            json!({
 764                ".zed": {
 765                    "tasks.json": r#"[
 766                        {
 767                            "label": "example task",
 768                            "command": "echo",
 769                            "args": ["4"]
 770                        },
 771                        {
 772                            "label": "another one",
 773                            "command": "echo",
 774                            "args": ["55"]
 775                        },
 776                    ]"#,
 777                },
 778                "a.ts": "a"
 779            }),
 780        )
 781        .await;
 782
 783        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 784        let (workspace, cx) =
 785            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 786
 787        let tasks_picker = open_spawn_tasks(&workspace, cx);
 788        assert_eq!(
 789            query(&tasks_picker, cx),
 790            "",
 791            "Initial query should be empty"
 792        );
 793        assert_eq!(
 794            task_names(&tasks_picker, cx),
 795            vec!["another one", "example task"],
 796            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 797        );
 798        drop(tasks_picker);
 799
 800        let _ = workspace
 801            .update_in(cx, |workspace, window, cx| {
 802                workspace.open_abs_path(
 803                    PathBuf::from(path!("/dir/a.ts")),
 804                    OpenOptions {
 805                        visible: Some(OpenVisible::All),
 806                        ..Default::default()
 807                    },
 808                    window,
 809                    cx,
 810                )
 811            })
 812            .await
 813            .unwrap();
 814        let tasks_picker = open_spawn_tasks(&workspace, cx);
 815        assert_eq!(
 816            task_names(&tasks_picker, cx),
 817            vec!["another one", "example task"],
 818            "Initial tasks should be listed in alphabetical order"
 819        );
 820
 821        let query_str = "tas";
 822        cx.simulate_input(query_str);
 823        assert_eq!(query(&tasks_picker, cx), query_str);
 824        assert_eq!(
 825            task_names(&tasks_picker, cx),
 826            vec!["example task"],
 827            "Only one task should match the query {query_str}"
 828        );
 829
 830        cx.dispatch_action(picker::ConfirmCompletion);
 831        assert_eq!(
 832            query(&tasks_picker, cx),
 833            "echo 4",
 834            "Query should be set to the selected task's command"
 835        );
 836        assert_eq!(
 837            task_names(&tasks_picker, cx),
 838            Vec::<String>::new(),
 839            "No task should be listed"
 840        );
 841        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 842
 843        let tasks_picker = open_spawn_tasks(&workspace, cx);
 844        assert_eq!(
 845            query(&tasks_picker, cx),
 846            "",
 847            "Query should be reset after confirming"
 848        );
 849        assert_eq!(
 850            task_names(&tasks_picker, cx),
 851            vec!["echo 4", "another one", "example task"],
 852            "New oneshot task should be listed first"
 853        );
 854
 855        let query_str = "echo 4";
 856        cx.simulate_input(query_str);
 857        assert_eq!(query(&tasks_picker, cx), query_str);
 858        assert_eq!(
 859            task_names(&tasks_picker, cx),
 860            vec!["echo 4"],
 861            "New oneshot should match custom command query"
 862        );
 863
 864        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 865        let tasks_picker = open_spawn_tasks(&workspace, cx);
 866        assert_eq!(
 867            query(&tasks_picker, cx),
 868            "",
 869            "Query should be reset after confirming"
 870        );
 871        assert_eq!(
 872            task_names(&tasks_picker, cx),
 873            vec![query_str, "another one", "example task"],
 874            "Last recently used one show task should be listed first"
 875        );
 876
 877        cx.dispatch_action(picker::ConfirmCompletion);
 878        assert_eq!(
 879            query(&tasks_picker, cx),
 880            query_str,
 881            "Query should be set to the custom task's name"
 882        );
 883        assert_eq!(
 884            task_names(&tasks_picker, cx),
 885            vec![query_str],
 886            "Only custom task should be listed"
 887        );
 888
 889        let query_str = "0";
 890        cx.simulate_input(query_str);
 891        assert_eq!(query(&tasks_picker, cx), "echo 40");
 892        assert_eq!(
 893            task_names(&tasks_picker, cx),
 894            Vec::<String>::new(),
 895            "New oneshot should not match any command query"
 896        );
 897
 898        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 899        let tasks_picker = open_spawn_tasks(&workspace, cx);
 900        assert_eq!(
 901            query(&tasks_picker, cx),
 902            "",
 903            "Query should be reset after confirming"
 904        );
 905        assert_eq!(
 906            task_names(&tasks_picker, cx),
 907            vec!["echo 4", "another one", "example task"],
 908            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 909        );
 910
 911        cx.dispatch_action(Spawn::ByName {
 912            task_name: "example task".to_string(),
 913            reveal_target: None,
 914        });
 915        let tasks_picker = workspace.update(cx, |workspace, cx| {
 916            workspace
 917                .active_modal::<TasksModal>(cx)
 918                .unwrap()
 919                .read(cx)
 920                .picker
 921                .clone()
 922        });
 923        assert_eq!(
 924            task_names(&tasks_picker, cx),
 925            vec!["echo 4", "another one", "example task"],
 926        );
 927    }
 928
 929    #[gpui::test]
 930    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 931        init_test(cx);
 932        let fs = FakeFs::new(cx.executor());
 933        fs.insert_tree(
 934            path!("/dir"),
 935            json!({
 936                ".zed": {
 937                    "tasks.json": r#"[
 938                        {
 939                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 940                            "command": "echo",
 941                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 942                        },
 943                        {
 944                            "label": "opened now: $ZED_WORKTREE_ROOT",
 945                            "command": "echo",
 946                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 947                        }
 948                    ]"#,
 949                },
 950                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 951                "file_with.odd_extension": "b",
 952            }),
 953        )
 954        .await;
 955
 956        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 957        let (workspace, cx) =
 958            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 959
 960        let tasks_picker = open_spawn_tasks(&workspace, cx);
 961        assert_eq!(
 962            task_names(&tasks_picker, cx),
 963            vec![concat!("opened now: ", path!("/dir")).to_string()],
 964            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 965        );
 966        tasks_picker.update(cx, |_, cx| {
 967            cx.emit(DismissEvent);
 968        });
 969        drop(tasks_picker);
 970        cx.executor().run_until_parked();
 971
 972        let _ = workspace
 973            .update_in(cx, |workspace, window, cx| {
 974                workspace.open_abs_path(
 975                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 976                    OpenOptions {
 977                        visible: Some(OpenVisible::All),
 978                        ..Default::default()
 979                    },
 980                    window,
 981                    cx,
 982                )
 983            })
 984            .await
 985            .unwrap();
 986        cx.executor().run_until_parked();
 987        let tasks_picker = open_spawn_tasks(&workspace, cx);
 988        assert_eq!(
 989            task_names(&tasks_picker, cx),
 990            vec![
 991                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 992                concat!("opened now: ", path!("/dir")).to_string(),
 993            ],
 994            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 995        );
 996        tasks_picker.update(cx, |_, cx| {
 997            cx.emit(DismissEvent);
 998        });
 999        drop(tasks_picker);
1000        cx.executor().run_until_parked();
1001
1002        let second_item = workspace
1003            .update_in(cx, |workspace, window, cx| {
1004                workspace.open_abs_path(
1005                    PathBuf::from(path!("/dir/file_without_extension")),
1006                    OpenOptions {
1007                        visible: Some(OpenVisible::All),
1008                        ..Default::default()
1009                    },
1010                    window,
1011                    cx,
1012                )
1013            })
1014            .await
1015            .unwrap();
1016
1017        let editor = cx
1018            .update(|_window, cx| second_item.act_as::<Editor>(cx))
1019            .unwrap();
1020        editor.update_in(cx, |editor, window, cx| {
1021            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1022                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1023            })
1024        });
1025        cx.executor().run_until_parked();
1026        let tasks_picker = open_spawn_tasks(&workspace, cx);
1027        assert_eq!(
1028            task_names(&tasks_picker, cx),
1029            vec![
1030                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1031                concat!("opened now: ", path!("/dir")).to_string(),
1032            ],
1033            "Opened buffer should fill the context, labels should be trimmed if long enough"
1034        );
1035        tasks_picker.update(cx, |_, cx| {
1036            cx.emit(DismissEvent);
1037        });
1038        drop(tasks_picker);
1039        cx.executor().run_until_parked();
1040    }
1041
1042    #[gpui::test]
1043    async fn test_language_task_filtering(cx: &mut TestAppContext) {
1044        init_test(cx);
1045        let fs = FakeFs::new(cx.executor());
1046        fs.insert_tree(
1047            path!("/dir"),
1048            json!({
1049                "a1.ts": "// a1",
1050                "a2.ts": "// a2",
1051                "b.rs": "// b",
1052            }),
1053        )
1054        .await;
1055
1056        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1057        project.read_with(cx, |project, _| {
1058            let language_registry = project.languages();
1059            language_registry.add(Arc::new(
1060                Language::new(
1061                    LanguageConfig {
1062                        name: "TypeScript".into(),
1063                        matcher: LanguageMatcher {
1064                            path_suffixes: vec!["ts".to_string()],
1065                            ..LanguageMatcher::default()
1066                        },
1067                        ..LanguageConfig::default()
1068                    },
1069                    None,
1070                )
1071                .with_context_provider(Some(Arc::new(
1072                    ContextProviderWithTasks::new(TaskTemplates(vec![
1073                        TaskTemplate {
1074                            label: "Task without variables".to_string(),
1075                            command: "npm run clean".to_string(),
1076                            ..TaskTemplate::default()
1077                        },
1078                        TaskTemplate {
1079                            label: "TypeScript task from file $ZED_FILE".to_string(),
1080                            command: "npm run build".to_string(),
1081                            ..TaskTemplate::default()
1082                        },
1083                        TaskTemplate {
1084                            label: "Another task from file $ZED_FILE".to_string(),
1085                            command: "npm run lint".to_string(),
1086                            ..TaskTemplate::default()
1087                        },
1088                    ])),
1089                ))),
1090            ));
1091            language_registry.add(Arc::new(
1092                Language::new(
1093                    LanguageConfig {
1094                        name: "Rust".into(),
1095                        matcher: LanguageMatcher {
1096                            path_suffixes: vec!["rs".to_string()],
1097                            ..LanguageMatcher::default()
1098                        },
1099                        ..LanguageConfig::default()
1100                    },
1101                    None,
1102                )
1103                .with_context_provider(Some(Arc::new(
1104                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1105                        label: "Rust task".to_string(),
1106                        command: "cargo check".into(),
1107                        ..TaskTemplate::default()
1108                    }])),
1109                ))),
1110            ));
1111        });
1112        let (workspace, cx) =
1113            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1114
1115        let _ts_file_1 = workspace
1116            .update_in(cx, |workspace, window, cx| {
1117                workspace.open_abs_path(
1118                    PathBuf::from(path!("/dir/a1.ts")),
1119                    OpenOptions {
1120                        visible: Some(OpenVisible::All),
1121                        ..Default::default()
1122                    },
1123                    window,
1124                    cx,
1125                )
1126            })
1127            .await
1128            .unwrap();
1129        let tasks_picker = open_spawn_tasks(&workspace, cx);
1130        assert_eq!(
1131            task_names(&tasks_picker, cx),
1132            vec![
1133                concat!("Another task from file ", path!("/dir/a1.ts")),
1134                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1135                "Task without variables",
1136            ],
1137            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1138        );
1139
1140        emulate_task_schedule(
1141            tasks_picker,
1142            &project,
1143            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1144            cx,
1145        );
1146
1147        let tasks_picker = open_spawn_tasks(&workspace, cx);
1148        assert_eq!(
1149            task_names(&tasks_picker, cx),
1150            vec![
1151                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1152                concat!("Another task from file ", path!("/dir/a1.ts")),
1153                "Task without variables",
1154            ],
1155            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1156            Tasks with the same labels and context are deduplicated."
1157        );
1158        tasks_picker.update(cx, |_, cx| {
1159            cx.emit(DismissEvent);
1160        });
1161        drop(tasks_picker);
1162        cx.executor().run_until_parked();
1163
1164        let _ts_file_2 = workspace
1165            .update_in(cx, |workspace, window, cx| {
1166                workspace.open_abs_path(
1167                    PathBuf::from(path!("/dir/a2.ts")),
1168                    OpenOptions {
1169                        visible: Some(OpenVisible::All),
1170                        ..Default::default()
1171                    },
1172                    window,
1173                    cx,
1174                )
1175            })
1176            .await
1177            .unwrap();
1178        let tasks_picker = open_spawn_tasks(&workspace, cx);
1179        assert_eq!(
1180            task_names(&tasks_picker, cx),
1181            vec![
1182                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1183                concat!("Another task from file ", path!("/dir/a2.ts")),
1184                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1185                "Task without variables",
1186            ],
1187            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1188        );
1189        tasks_picker.update(cx, |_, cx| {
1190            cx.emit(DismissEvent);
1191        });
1192        drop(tasks_picker);
1193        cx.executor().run_until_parked();
1194
1195        let _rs_file = workspace
1196            .update_in(cx, |workspace, window, cx| {
1197                workspace.open_abs_path(
1198                    PathBuf::from(path!("/dir/b.rs")),
1199                    OpenOptions {
1200                        visible: Some(OpenVisible::All),
1201                        ..Default::default()
1202                    },
1203                    window,
1204                    cx,
1205                )
1206            })
1207            .await
1208            .unwrap();
1209        let tasks_picker = open_spawn_tasks(&workspace, cx);
1210        assert_eq!(
1211            task_names(&tasks_picker, cx),
1212            vec!["Rust task"],
1213            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1214        );
1215
1216        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1217        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1218        let _ts_file_2 = workspace
1219            .update_in(cx, |workspace, window, cx| {
1220                workspace.open_abs_path(
1221                    PathBuf::from(path!("/dir/a2.ts")),
1222                    OpenOptions {
1223                        visible: Some(OpenVisible::All),
1224                        ..Default::default()
1225                    },
1226                    window,
1227                    cx,
1228                )
1229            })
1230            .await
1231            .unwrap();
1232        let tasks_picker = open_spawn_tasks(&workspace, cx);
1233        assert_eq!(
1234            task_names(&tasks_picker, cx),
1235            vec![
1236                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1237                concat!("Another task from file ", path!("/dir/a2.ts")),
1238                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1239                "Task without variables",
1240            ],
1241            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1242            same TS spawn history should be restored"
1243        );
1244    }
1245
1246    fn emulate_task_schedule(
1247        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1248        project: &Entity<Project>,
1249        scheduled_task_label: &str,
1250        cx: &mut VisualTestContext,
1251    ) {
1252        let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1253            tasks_picker
1254                .delegate
1255                .candidates
1256                .iter()
1257                .flatten()
1258                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1259                .cloned()
1260                .unwrap()
1261        });
1262        project.update(cx, |project, cx| {
1263            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1264                task_inventory.update(cx, |inventory, _| {
1265                    let (kind, task) = scheduled_task;
1266                    inventory.task_scheduled(kind, task);
1267                });
1268            }
1269        });
1270        tasks_picker.update(cx, |_, cx| {
1271            cx.emit(DismissEvent);
1272        });
1273        drop(tasks_picker);
1274        cx.executor().run_until_parked()
1275    }
1276
1277    fn open_spawn_tasks(
1278        workspace: &Entity<Workspace>,
1279        cx: &mut VisualTestContext,
1280    ) -> Entity<Picker<TasksModalDelegate>> {
1281        cx.dispatch_action(Spawn::modal());
1282        workspace.update(cx, |workspace, cx| {
1283            workspace
1284                .active_modal::<TasksModal>(cx)
1285                .expect("no task modal after `Spawn` action was dispatched")
1286                .read(cx)
1287                .picker
1288                .clone()
1289        })
1290    }
1291
1292    fn query(
1293        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1294        cx: &mut VisualTestContext,
1295    ) -> String {
1296        spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1297    }
1298
1299    fn task_names(
1300        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1301        cx: &mut VisualTestContext,
1302    ) -> Vec<String> {
1303        spawn_tasks.read_with(cx, |spawn_tasks, _| {
1304            spawn_tasks
1305                .delegate
1306                .matches
1307                .iter()
1308                .map(|hit| hit.string.clone())
1309                .collect::<Vec<_>>()
1310        })
1311    }
1312}