modal.rs

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