modal.rs

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