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