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