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 = 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            color: Color::Default,
 486        };
 487        let icon = match source_kind {
 488            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
 489            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
 490            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
 491            TaskSourceKind::Lsp {
 492                language_name: name,
 493                ..
 494            }
 495            | TaskSourceKind::Language { name, .. } => file_icons::FileIcons::get(cx)
 496                .get_icon_for_type(&name.to_lowercase(), cx)
 497                .map(Icon::from_path),
 498        }
 499        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 500        let indicator = if matches!(source_kind, TaskSourceKind::Lsp { .. }) {
 501            Some(Indicator::icon(
 502                Icon::new(IconName::BoltOutlined).size(IconSize::Small),
 503            ))
 504        } else {
 505            None
 506        };
 507        let icon = icon.map(|icon| {
 508            IconWithIndicator::new(icon, indicator)
 509                .indicator_border_color(Some(cx.theme().colors().border_transparent))
 510        });
 511        let history_run_icon = if Some(ix) <= self.divider_index {
 512            Some(
 513                Icon::new(IconName::HistoryRerun)
 514                    .color(Color::Muted)
 515                    .size(IconSize::Small)
 516                    .into_any_element(),
 517            )
 518        } else {
 519            Some(
 520                v_flex()
 521                    .flex_none()
 522                    .size(IconSize::Small.rems())
 523                    .into_any_element(),
 524            )
 525        };
 526
 527        Some(
 528            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 529                .inset(true)
 530                .start_slot::<IconWithIndicator>(icon)
 531                .end_slot::<AnyElement>(
 532                    h_flex()
 533                        .gap_1()
 534                        .child(Label::new(truncate_and_trailoff(
 535                            &template
 536                                .tags
 537                                .iter()
 538                                .map(|tag| format!("#{}", tag))
 539                                .collect::<Vec<_>>()
 540                                .join(" "),
 541                            MAX_TAGS_LINE_LEN,
 542                        )))
 543                        .flex_none()
 544                        .child(history_run_icon.unwrap())
 545                        .into_any_element(),
 546                )
 547                .spacing(ListItemSpacing::Sparse)
 548                .when_some(tooltip_label, |list_item, item_label| {
 549                    list_item.tooltip(move |_, _| item_label.clone())
 550                })
 551                .map(|item| {
 552                    if matches!(source_kind, TaskSourceKind::UserInput)
 553                        || Some(ix) <= self.divider_index
 554                    {
 555                        let task_index = hit.candidate_id;
 556                        let delete_button = div().child(
 557                            IconButton::new("delete", IconName::Close)
 558                                .shape(IconButtonShape::Square)
 559                                .icon_color(Color::Muted)
 560                                .size(ButtonSize::None)
 561                                .icon_size(IconSize::XSmall)
 562                                .on_click(cx.listener(move |picker, _event, window, cx| {
 563                                    cx.stop_propagation();
 564                                    window.prevent_default();
 565
 566                                    picker.delegate.delete_previously_used(task_index, cx);
 567                                    picker.delegate.last_used_candidate_index = picker
 568                                        .delegate
 569                                        .last_used_candidate_index
 570                                        .unwrap_or(0)
 571                                        .checked_sub(1);
 572                                    picker.refresh(window, cx);
 573                                }))
 574                                .tooltip(|_, cx| {
 575                                    Tooltip::simple("Delete Previously Scheduled Task", cx)
 576                                }),
 577                        );
 578                        item.end_hover_slot(delete_button)
 579                    } else {
 580                        item
 581                    }
 582                })
 583                .toggle_state(selected)
 584                .child(highlighted_location.render(window, cx)),
 585        )
 586    }
 587
 588    fn confirm_completion(
 589        &mut self,
 590        _: String,
 591        _window: &mut Window,
 592        _: &mut Context<Picker<Self>>,
 593    ) -> Option<String> {
 594        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 595        let tasks = self.candidates.as_ref()?;
 596        let (_, task) = tasks.get(task_index)?;
 597        Some(task.resolved.command_label.clone())
 598    }
 599
 600    fn confirm_input(
 601        &mut self,
 602        omit_history_entry: bool,
 603        window: &mut Window,
 604        cx: &mut Context<Picker<Self>>,
 605    ) {
 606        let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
 607            return;
 608        };
 609
 610        if let Some(TaskOverrides {
 611            reveal_target: Some(reveal_target),
 612        }) = self.task_overrides
 613        {
 614            task.resolved.reveal_target = reveal_target;
 615        }
 616        self.workspace
 617            .update(cx, |workspace, cx| {
 618                workspace.schedule_resolved_task(
 619                    task_source_kind,
 620                    task,
 621                    omit_history_entry,
 622                    window,
 623                    cx,
 624                )
 625            })
 626            .ok();
 627        cx.emit(DismissEvent);
 628    }
 629
 630    fn separators_after_indices(&self) -> Vec<usize> {
 631        if let Some(i) = self.divider_index {
 632            vec![i]
 633        } else {
 634            Vec::new()
 635        }
 636    }
 637
 638    fn render_footer(
 639        &self,
 640        window: &mut Window,
 641        cx: &mut Context<Picker<Self>>,
 642    ) -> Option<gpui::AnyElement> {
 643        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 644        let current_modifiers = window.modifiers();
 645        let left_button = if self
 646            .task_store
 647            .read(cx)
 648            .task_inventory()?
 649            .read(cx)
 650            .last_scheduled_task(None)
 651            .is_some()
 652        {
 653            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 654        } else {
 655            None
 656        };
 657        Some(
 658            h_flex()
 659                .w_full()
 660                .p_1p5()
 661                .justify_between()
 662                .border_t_1()
 663                .border_color(cx.theme().colors().border_variant)
 664                .child(
 665                    left_button
 666                        .map(|(label, action)| {
 667                            let keybind = KeyBinding::for_action(&*action, cx);
 668
 669                            Button::new("edit-current-task", label)
 670                                .key_binding(keybind)
 671                                .on_click(move |_, window, cx| {
 672                                    window.dispatch_action(action.boxed_clone(), cx);
 673                                })
 674                                .into_any_element()
 675                        })
 676                        .unwrap_or_else(|| h_flex().into_any_element()),
 677                )
 678                .map(|this| {
 679                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 680                    {
 681                        let action = picker::ConfirmInput {
 682                            secondary: current_modifiers.secondary(),
 683                        }
 684                        .boxed_clone();
 685                        this.child({
 686                            let spawn_oneshot_label = if current_modifiers.secondary() {
 687                                "Spawn Oneshot Without History"
 688                            } else {
 689                                "Spawn Oneshot"
 690                            };
 691
 692                            Button::new("spawn-onehshot", spawn_oneshot_label)
 693                                .key_binding(KeyBinding::for_action(&*action, cx))
 694                                .on_click(move |_, window, cx| {
 695                                    window.dispatch_action(action.boxed_clone(), cx)
 696                                })
 697                        })
 698                    } else if current_modifiers.secondary() {
 699                        this.child({
 700                            let label = if is_recent_selected {
 701                                "Rerun Without History"
 702                            } else {
 703                                "Spawn Without History"
 704                            };
 705                            Button::new("spawn", label)
 706                                .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
 707                                .on_click(move |_, window, cx| {
 708                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
 709                                })
 710                        })
 711                    } else {
 712                        this.child({
 713                            let run_entry_label =
 714                                if is_recent_selected { "Rerun" } else { "Spawn" };
 715
 716                            Button::new("spawn", run_entry_label)
 717                                .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
 718                                .on_click(|_, window, cx| {
 719                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 720                                })
 721                        })
 722                    }
 723                })
 724                .into_any_element(),
 725        )
 726    }
 727}
 728
 729fn string_match_candidates<'a>(
 730    candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 731) -> Vec<StringMatchCandidate> {
 732    candidates
 733        .into_iter()
 734        .enumerate()
 735        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 736        .collect()
 737}
 738
 739#[cfg(test)]
 740mod tests {
 741    use std::{path::PathBuf, sync::Arc};
 742
 743    use editor::{Editor, SelectionEffects};
 744    use gpui::{TestAppContext, VisualTestContext};
 745    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 746    use project::{ContextProviderWithTasks, FakeFs, Project};
 747    use serde_json::json;
 748    use task::TaskTemplates;
 749    use util::path;
 750    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 751
 752    use crate::{modal::Spawn, tests::init_test};
 753
 754    use super::*;
 755
 756    #[gpui::test]
 757    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 758        init_test(cx);
 759        let fs = FakeFs::new(cx.executor());
 760        fs.insert_tree(
 761            path!("/dir"),
 762            json!({
 763                ".zed": {
 764                    "tasks.json": r#"[
 765                        {
 766                            "label": "example task",
 767                            "command": "echo",
 768                            "args": ["4"]
 769                        },
 770                        {
 771                            "label": "another one",
 772                            "command": "echo",
 773                            "args": ["55"]
 774                        },
 775                    ]"#,
 776                },
 777                "a.ts": "a"
 778            }),
 779        )
 780        .await;
 781
 782        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 783        let (workspace, cx) =
 784            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 785
 786        let tasks_picker = open_spawn_tasks(&workspace, cx);
 787        assert_eq!(
 788            query(&tasks_picker, cx),
 789            "",
 790            "Initial query should be empty"
 791        );
 792        assert_eq!(
 793            task_names(&tasks_picker, cx),
 794            vec!["another one", "example task"],
 795            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 796        );
 797        drop(tasks_picker);
 798
 799        let _ = workspace
 800            .update_in(cx, |workspace, window, cx| {
 801                workspace.open_abs_path(
 802                    PathBuf::from(path!("/dir/a.ts")),
 803                    OpenOptions {
 804                        visible: Some(OpenVisible::All),
 805                        ..Default::default()
 806                    },
 807                    window,
 808                    cx,
 809                )
 810            })
 811            .await
 812            .unwrap();
 813        let tasks_picker = open_spawn_tasks(&workspace, cx);
 814        assert_eq!(
 815            task_names(&tasks_picker, cx),
 816            vec!["another one", "example task"],
 817            "Initial tasks should be listed in alphabetical order"
 818        );
 819
 820        let query_str = "tas";
 821        cx.simulate_input(query_str);
 822        assert_eq!(query(&tasks_picker, cx), query_str);
 823        assert_eq!(
 824            task_names(&tasks_picker, cx),
 825            vec!["example task"],
 826            "Only one task should match the query {query_str}"
 827        );
 828
 829        cx.dispatch_action(picker::ConfirmCompletion);
 830        assert_eq!(
 831            query(&tasks_picker, cx),
 832            "echo 4",
 833            "Query should be set to the selected task's command"
 834        );
 835        assert_eq!(
 836            task_names(&tasks_picker, cx),
 837            Vec::<String>::new(),
 838            "No task should be listed"
 839        );
 840        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 841
 842        let tasks_picker = open_spawn_tasks(&workspace, cx);
 843        assert_eq!(
 844            query(&tasks_picker, cx),
 845            "",
 846            "Query should be reset after confirming"
 847        );
 848        assert_eq!(
 849            task_names(&tasks_picker, cx),
 850            vec!["echo 4", "another one", "example task"],
 851            "New oneshot task should be listed first"
 852        );
 853
 854        let query_str = "echo 4";
 855        cx.simulate_input(query_str);
 856        assert_eq!(query(&tasks_picker, cx), query_str);
 857        assert_eq!(
 858            task_names(&tasks_picker, cx),
 859            vec!["echo 4"],
 860            "New oneshot should match custom command query"
 861        );
 862
 863        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 864        let tasks_picker = open_spawn_tasks(&workspace, cx);
 865        assert_eq!(
 866            query(&tasks_picker, cx),
 867            "",
 868            "Query should be reset after confirming"
 869        );
 870        assert_eq!(
 871            task_names(&tasks_picker, cx),
 872            vec![query_str, "another one", "example task"],
 873            "Last recently used one show task should be listed first"
 874        );
 875
 876        cx.dispatch_action(picker::ConfirmCompletion);
 877        assert_eq!(
 878            query(&tasks_picker, cx),
 879            query_str,
 880            "Query should be set to the custom task's name"
 881        );
 882        assert_eq!(
 883            task_names(&tasks_picker, cx),
 884            vec![query_str],
 885            "Only custom task should be listed"
 886        );
 887
 888        let query_str = "0";
 889        cx.simulate_input(query_str);
 890        assert_eq!(query(&tasks_picker, cx), "echo 40");
 891        assert_eq!(
 892            task_names(&tasks_picker, cx),
 893            Vec::<String>::new(),
 894            "New oneshot should not match any command query"
 895        );
 896
 897        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 898        let tasks_picker = open_spawn_tasks(&workspace, cx);
 899        assert_eq!(
 900            query(&tasks_picker, cx),
 901            "",
 902            "Query should be reset after confirming"
 903        );
 904        assert_eq!(
 905            task_names(&tasks_picker, cx),
 906            vec!["echo 4", "another one", "example task"],
 907            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 908        );
 909
 910        cx.dispatch_action(Spawn::ByName {
 911            task_name: "example task".to_string(),
 912            reveal_target: None,
 913        });
 914        let tasks_picker = workspace.update(cx, |workspace, cx| {
 915            workspace
 916                .active_modal::<TasksModal>(cx)
 917                .unwrap()
 918                .read(cx)
 919                .picker
 920                .clone()
 921        });
 922        assert_eq!(
 923            task_names(&tasks_picker, cx),
 924            vec!["echo 4", "another one", "example task"],
 925        );
 926    }
 927
 928    #[gpui::test]
 929    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 930        init_test(cx);
 931        let fs = FakeFs::new(cx.executor());
 932        fs.insert_tree(
 933            path!("/dir"),
 934            json!({
 935                ".zed": {
 936                    "tasks.json": r#"[
 937                        {
 938                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 939                            "command": "echo",
 940                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 941                        },
 942                        {
 943                            "label": "opened now: $ZED_WORKTREE_ROOT",
 944                            "command": "echo",
 945                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 946                        }
 947                    ]"#,
 948                },
 949                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 950                "file_with.odd_extension": "b",
 951            }),
 952        )
 953        .await;
 954
 955        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 956        let (workspace, cx) =
 957            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 958
 959        let tasks_picker = open_spawn_tasks(&workspace, cx);
 960        assert_eq!(
 961            task_names(&tasks_picker, cx),
 962            vec![concat!("opened now: ", path!("/dir")).to_string()],
 963            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 964        );
 965        tasks_picker.update(cx, |_, cx| {
 966            cx.emit(DismissEvent);
 967        });
 968        drop(tasks_picker);
 969        cx.executor().run_until_parked();
 970
 971        let _ = workspace
 972            .update_in(cx, |workspace, window, cx| {
 973                workspace.open_abs_path(
 974                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 975                    OpenOptions {
 976                        visible: Some(OpenVisible::All),
 977                        ..Default::default()
 978                    },
 979                    window,
 980                    cx,
 981                )
 982            })
 983            .await
 984            .unwrap();
 985        cx.executor().run_until_parked();
 986        let tasks_picker = open_spawn_tasks(&workspace, cx);
 987        assert_eq!(
 988            task_names(&tasks_picker, cx),
 989            vec![
 990                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 991                concat!("opened now: ", path!("/dir")).to_string(),
 992            ],
 993            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 994        );
 995        tasks_picker.update(cx, |_, cx| {
 996            cx.emit(DismissEvent);
 997        });
 998        drop(tasks_picker);
 999        cx.executor().run_until_parked();
1000
1001        let second_item = workspace
1002            .update_in(cx, |workspace, window, cx| {
1003                workspace.open_abs_path(
1004                    PathBuf::from(path!("/dir/file_without_extension")),
1005                    OpenOptions {
1006                        visible: Some(OpenVisible::All),
1007                        ..Default::default()
1008                    },
1009                    window,
1010                    cx,
1011                )
1012            })
1013            .await
1014            .unwrap();
1015
1016        let editor = cx
1017            .update(|_window, cx| second_item.act_as::<Editor>(cx))
1018            .unwrap();
1019        editor.update_in(cx, |editor, window, cx| {
1020            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1021                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1022            })
1023        });
1024        cx.executor().run_until_parked();
1025        let tasks_picker = open_spawn_tasks(&workspace, cx);
1026        assert_eq!(
1027            task_names(&tasks_picker, cx),
1028            vec![
1029                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1030                concat!("opened now: ", path!("/dir")).to_string(),
1031            ],
1032            "Opened buffer should fill the context, labels should be trimmed if long enough"
1033        );
1034        tasks_picker.update(cx, |_, cx| {
1035            cx.emit(DismissEvent);
1036        });
1037        drop(tasks_picker);
1038        cx.executor().run_until_parked();
1039    }
1040
1041    #[gpui::test]
1042    async fn test_language_task_filtering(cx: &mut TestAppContext) {
1043        init_test(cx);
1044        let fs = FakeFs::new(cx.executor());
1045        fs.insert_tree(
1046            path!("/dir"),
1047            json!({
1048                "a1.ts": "// a1",
1049                "a2.ts": "// a2",
1050                "b.rs": "// b",
1051            }),
1052        )
1053        .await;
1054
1055        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1056        project.read_with(cx, |project, _| {
1057            let language_registry = project.languages();
1058            language_registry.add(Arc::new(
1059                Language::new(
1060                    LanguageConfig {
1061                        name: "TypeScript".into(),
1062                        matcher: LanguageMatcher {
1063                            path_suffixes: vec!["ts".to_string()],
1064                            ..LanguageMatcher::default()
1065                        },
1066                        ..LanguageConfig::default()
1067                    },
1068                    None,
1069                )
1070                .with_context_provider(Some(Arc::new(
1071                    ContextProviderWithTasks::new(TaskTemplates(vec![
1072                        TaskTemplate {
1073                            label: "Task without variables".to_string(),
1074                            command: "npm run clean".to_string(),
1075                            ..TaskTemplate::default()
1076                        },
1077                        TaskTemplate {
1078                            label: "TypeScript task from file $ZED_FILE".to_string(),
1079                            command: "npm run build".to_string(),
1080                            ..TaskTemplate::default()
1081                        },
1082                        TaskTemplate {
1083                            label: "Another task from file $ZED_FILE".to_string(),
1084                            command: "npm run lint".to_string(),
1085                            ..TaskTemplate::default()
1086                        },
1087                    ])),
1088                ))),
1089            ));
1090            language_registry.add(Arc::new(
1091                Language::new(
1092                    LanguageConfig {
1093                        name: "Rust".into(),
1094                        matcher: LanguageMatcher {
1095                            path_suffixes: vec!["rs".to_string()],
1096                            ..LanguageMatcher::default()
1097                        },
1098                        ..LanguageConfig::default()
1099                    },
1100                    None,
1101                )
1102                .with_context_provider(Some(Arc::new(
1103                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1104                        label: "Rust task".to_string(),
1105                        command: "cargo check".into(),
1106                        ..TaskTemplate::default()
1107                    }])),
1108                ))),
1109            ));
1110        });
1111        let (workspace, cx) =
1112            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1113
1114        let _ts_file_1 = workspace
1115            .update_in(cx, |workspace, window, cx| {
1116                workspace.open_abs_path(
1117                    PathBuf::from(path!("/dir/a1.ts")),
1118                    OpenOptions {
1119                        visible: Some(OpenVisible::All),
1120                        ..Default::default()
1121                    },
1122                    window,
1123                    cx,
1124                )
1125            })
1126            .await
1127            .unwrap();
1128        let tasks_picker = open_spawn_tasks(&workspace, cx);
1129        assert_eq!(
1130            task_names(&tasks_picker, cx),
1131            vec![
1132                concat!("Another task from file ", path!("/dir/a1.ts")),
1133                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1134                "Task without variables",
1135            ],
1136            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1137        );
1138
1139        emulate_task_schedule(
1140            tasks_picker,
1141            &project,
1142            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1143            cx,
1144        );
1145
1146        let tasks_picker = open_spawn_tasks(&workspace, cx);
1147        assert_eq!(
1148            task_names(&tasks_picker, cx),
1149            vec![
1150                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1151                concat!("Another task from file ", path!("/dir/a1.ts")),
1152                "Task without variables",
1153            ],
1154            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1155            Tasks with the same labels and context are deduplicated."
1156        );
1157        tasks_picker.update(cx, |_, cx| {
1158            cx.emit(DismissEvent);
1159        });
1160        drop(tasks_picker);
1161        cx.executor().run_until_parked();
1162
1163        let _ts_file_2 = workspace
1164            .update_in(cx, |workspace, window, cx| {
1165                workspace.open_abs_path(
1166                    PathBuf::from(path!("/dir/a2.ts")),
1167                    OpenOptions {
1168                        visible: Some(OpenVisible::All),
1169                        ..Default::default()
1170                    },
1171                    window,
1172                    cx,
1173                )
1174            })
1175            .await
1176            .unwrap();
1177        let tasks_picker = open_spawn_tasks(&workspace, cx);
1178        assert_eq!(
1179            task_names(&tasks_picker, cx),
1180            vec![
1181                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1182                concat!("Another task from file ", path!("/dir/a2.ts")),
1183                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1184                "Task without variables",
1185            ],
1186            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1187        );
1188        tasks_picker.update(cx, |_, cx| {
1189            cx.emit(DismissEvent);
1190        });
1191        drop(tasks_picker);
1192        cx.executor().run_until_parked();
1193
1194        let _rs_file = workspace
1195            .update_in(cx, |workspace, window, cx| {
1196                workspace.open_abs_path(
1197                    PathBuf::from(path!("/dir/b.rs")),
1198                    OpenOptions {
1199                        visible: Some(OpenVisible::All),
1200                        ..Default::default()
1201                    },
1202                    window,
1203                    cx,
1204                )
1205            })
1206            .await
1207            .unwrap();
1208        let tasks_picker = open_spawn_tasks(&workspace, cx);
1209        assert_eq!(
1210            task_names(&tasks_picker, cx),
1211            vec!["Rust task"],
1212            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1213        );
1214
1215        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1216        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1217        let _ts_file_2 = workspace
1218            .update_in(cx, |workspace, window, cx| {
1219                workspace.open_abs_path(
1220                    PathBuf::from(path!("/dir/a2.ts")),
1221                    OpenOptions {
1222                        visible: Some(OpenVisible::All),
1223                        ..Default::default()
1224                    },
1225                    window,
1226                    cx,
1227                )
1228            })
1229            .await
1230            .unwrap();
1231        let tasks_picker = open_spawn_tasks(&workspace, cx);
1232        assert_eq!(
1233            task_names(&tasks_picker, cx),
1234            vec![
1235                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1236                concat!("Another task from file ", path!("/dir/a2.ts")),
1237                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1238                "Task without variables",
1239            ],
1240            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1241            same TS spawn history should be restored"
1242        );
1243    }
1244
1245    fn emulate_task_schedule(
1246        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1247        project: &Entity<Project>,
1248        scheduled_task_label: &str,
1249        cx: &mut VisualTestContext,
1250    ) {
1251        let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1252            tasks_picker
1253                .delegate
1254                .candidates
1255                .iter()
1256                .flatten()
1257                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1258                .cloned()
1259                .unwrap()
1260        });
1261        project.update(cx, |project, cx| {
1262            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1263                task_inventory.update(cx, |inventory, _| {
1264                    let (kind, task) = scheduled_task;
1265                    inventory.task_scheduled(kind, task);
1266                });
1267            }
1268        });
1269        tasks_picker.update(cx, |_, cx| {
1270            cx.emit(DismissEvent);
1271        });
1272        drop(tasks_picker);
1273        cx.executor().run_until_parked()
1274    }
1275
1276    fn open_spawn_tasks(
1277        workspace: &Entity<Workspace>,
1278        cx: &mut VisualTestContext,
1279    ) -> Entity<Picker<TasksModalDelegate>> {
1280        cx.dispatch_action(Spawn::modal());
1281        workspace.update(cx, |workspace, cx| {
1282            workspace
1283                .active_modal::<TasksModal>(cx)
1284                .expect("no task modal after `Spawn` action was dispatched")
1285                .read(cx)
1286                .picker
1287                .clone()
1288        })
1289    }
1290
1291    fn query(
1292        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1293        cx: &mut VisualTestContext,
1294    ) -> String {
1295        spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1296    }
1297
1298    fn task_names(
1299        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1300        cx: &mut VisualTestContext,
1301    ) -> Vec<String> {
1302        spawn_tasks.read_with(cx, |spawn_tasks, _| {
1303            spawn_tasks
1304                .delegate
1305                .matches
1306                .iter()
1307                .map(|hit| hit.string.clone())
1308                .collect::<Vec<_>>()
1309        })
1310    }
1311}