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