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