modal.rs

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