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, MultiWorkspace, 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 (multi_workspace, cx) =
 791            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 792        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 793
 794        let tasks_picker = open_spawn_tasks(&workspace, cx);
 795        assert_eq!(
 796            query(&tasks_picker, cx),
 797            "",
 798            "Initial query should be empty"
 799        );
 800        assert_eq!(
 801            task_names(&tasks_picker, cx),
 802            vec!["another one", "example task"],
 803            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 804        );
 805        drop(tasks_picker);
 806
 807        let _ = workspace
 808            .update_in(cx, |workspace, window, cx| {
 809                workspace.open_abs_path(
 810                    PathBuf::from(path!("/dir/a.ts")),
 811                    OpenOptions {
 812                        visible: Some(OpenVisible::All),
 813                        ..Default::default()
 814                    },
 815                    window,
 816                    cx,
 817                )
 818            })
 819            .await
 820            .unwrap();
 821        let tasks_picker = open_spawn_tasks(&workspace, cx);
 822        assert_eq!(
 823            task_names(&tasks_picker, cx),
 824            vec!["another one", "example task"],
 825            "Initial tasks should be listed in alphabetical order"
 826        );
 827
 828        let query_str = "tas";
 829        cx.simulate_input(query_str);
 830        assert_eq!(query(&tasks_picker, cx), query_str);
 831        assert_eq!(
 832            task_names(&tasks_picker, cx),
 833            vec!["example task"],
 834            "Only one task should match the query {query_str}"
 835        );
 836
 837        cx.dispatch_action(picker::ConfirmCompletion);
 838        assert_eq!(
 839            query(&tasks_picker, cx),
 840            "echo 4",
 841            "Query should be set to the selected task's command"
 842        );
 843        assert_eq!(
 844            task_names(&tasks_picker, cx),
 845            Vec::<String>::new(),
 846            "No task should be listed"
 847        );
 848        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 849
 850        let tasks_picker = open_spawn_tasks(&workspace, cx);
 851        assert_eq!(
 852            query(&tasks_picker, cx),
 853            "",
 854            "Query should be reset after confirming"
 855        );
 856        assert_eq!(
 857            task_names(&tasks_picker, cx),
 858            vec!["echo 4", "another one", "example task"],
 859            "New oneshot task should be listed first"
 860        );
 861
 862        let query_str = "echo 4";
 863        cx.simulate_input(query_str);
 864        assert_eq!(query(&tasks_picker, cx), query_str);
 865        assert_eq!(
 866            task_names(&tasks_picker, cx),
 867            vec!["echo 4"],
 868            "New oneshot should match custom command query"
 869        );
 870
 871        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 872        let tasks_picker = open_spawn_tasks(&workspace, cx);
 873        assert_eq!(
 874            query(&tasks_picker, cx),
 875            "",
 876            "Query should be reset after confirming"
 877        );
 878        assert_eq!(
 879            task_names(&tasks_picker, cx),
 880            vec![query_str, "another one", "example task"],
 881            "Last recently used one show task should be listed first"
 882        );
 883
 884        cx.dispatch_action(picker::ConfirmCompletion);
 885        assert_eq!(
 886            query(&tasks_picker, cx),
 887            query_str,
 888            "Query should be set to the custom task's name"
 889        );
 890        assert_eq!(
 891            task_names(&tasks_picker, cx),
 892            vec![query_str],
 893            "Only custom task should be listed"
 894        );
 895
 896        let query_str = "0";
 897        cx.simulate_input(query_str);
 898        assert_eq!(query(&tasks_picker, cx), "echo 40");
 899        assert_eq!(
 900            task_names(&tasks_picker, cx),
 901            Vec::<String>::new(),
 902            "New oneshot should not match any command query"
 903        );
 904
 905        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 906        let tasks_picker = open_spawn_tasks(&workspace, cx);
 907        assert_eq!(
 908            query(&tasks_picker, cx),
 909            "",
 910            "Query should be reset after confirming"
 911        );
 912        assert_eq!(
 913            task_names(&tasks_picker, cx),
 914            vec!["echo 4", "another one", "example task"],
 915            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 916        );
 917
 918        cx.dispatch_action(Spawn::ByName {
 919            task_name: "example task".to_string(),
 920            reveal_target: None,
 921        });
 922        let tasks_picker = workspace.update(cx, |workspace, cx| {
 923            workspace
 924                .active_modal::<TasksModal>(cx)
 925                .unwrap()
 926                .read(cx)
 927                .picker
 928                .clone()
 929        });
 930        assert_eq!(
 931            task_names(&tasks_picker, cx),
 932            vec!["echo 4", "another one", "example task"],
 933        );
 934    }
 935
 936    #[gpui::test]
 937    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 938        init_test(cx);
 939        let fs = FakeFs::new(cx.executor());
 940        fs.insert_tree(
 941            path!("/dir"),
 942            json!({
 943                ".zed": {
 944                    "tasks.json": r#"[
 945                        {
 946                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 947                            "command": "echo",
 948                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 949                        },
 950                        {
 951                            "label": "opened now: $ZED_WORKTREE_ROOT",
 952                            "command": "echo",
 953                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 954                        }
 955                    ]"#,
 956                },
 957                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 958                "file_with.odd_extension": "b",
 959            }),
 960        )
 961        .await;
 962
 963        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 964        let (multi_workspace, cx) =
 965            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 966        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 967
 968        let tasks_picker = open_spawn_tasks(&workspace, cx);
 969        assert_eq!(
 970            task_names(&tasks_picker, cx),
 971            vec![concat!("opened now: ", path!("/dir")).to_string()],
 972            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 973        );
 974        tasks_picker.update(cx, |_, cx| {
 975            cx.emit(DismissEvent);
 976        });
 977        drop(tasks_picker);
 978        cx.executor().run_until_parked();
 979
 980        let _ = workspace
 981            .update_in(cx, |workspace, window, cx| {
 982                workspace.open_abs_path(
 983                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 984                    OpenOptions {
 985                        visible: Some(OpenVisible::All),
 986                        ..Default::default()
 987                    },
 988                    window,
 989                    cx,
 990                )
 991            })
 992            .await
 993            .unwrap();
 994        cx.executor().run_until_parked();
 995        let tasks_picker = open_spawn_tasks(&workspace, cx);
 996        assert_eq!(
 997            task_names(&tasks_picker, cx),
 998            vec![
 999                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
1000                concat!("opened now: ", path!("/dir")).to_string(),
1001            ],
1002            "Second opened buffer should fill the context, labels should be trimmed if long enough"
1003        );
1004        tasks_picker.update(cx, |_, cx| {
1005            cx.emit(DismissEvent);
1006        });
1007        drop(tasks_picker);
1008        cx.executor().run_until_parked();
1009
1010        let second_item = workspace
1011            .update_in(cx, |workspace, window, cx| {
1012                workspace.open_abs_path(
1013                    PathBuf::from(path!("/dir/file_without_extension")),
1014                    OpenOptions {
1015                        visible: Some(OpenVisible::All),
1016                        ..Default::default()
1017                    },
1018                    window,
1019                    cx,
1020                )
1021            })
1022            .await
1023            .unwrap();
1024
1025        let editor = cx
1026            .update(|_window, cx| second_item.act_as::<Editor>(cx))
1027            .unwrap();
1028        editor.update_in(cx, |editor, window, cx| {
1029            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1030                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1031            })
1032        });
1033        cx.executor().run_until_parked();
1034        let tasks_picker = open_spawn_tasks(&workspace, cx);
1035        assert_eq!(
1036            task_names(&tasks_picker, cx),
1037            vec![
1038                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1039                concat!("opened now: ", path!("/dir")).to_string(),
1040            ],
1041            "Opened buffer should fill the context, labels should be trimmed if long enough"
1042        );
1043        tasks_picker.update(cx, |_, cx| {
1044            cx.emit(DismissEvent);
1045        });
1046        drop(tasks_picker);
1047        cx.executor().run_until_parked();
1048    }
1049
1050    #[gpui::test]
1051    async fn test_language_task_filtering(cx: &mut TestAppContext) {
1052        init_test(cx);
1053        let fs = FakeFs::new(cx.executor());
1054        fs.insert_tree(
1055            path!("/dir"),
1056            json!({
1057                "a1.ts": "// a1",
1058                "a2.ts": "// a2",
1059                "b.rs": "// b",
1060            }),
1061        )
1062        .await;
1063
1064        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1065        project.read_with(cx, |project, _| {
1066            let language_registry = project.languages();
1067            language_registry.add(Arc::new(
1068                Language::new(
1069                    LanguageConfig {
1070                        name: "TypeScript".into(),
1071                        matcher: LanguageMatcher {
1072                            path_suffixes: vec!["ts".to_string()],
1073                            ..LanguageMatcher::default()
1074                        },
1075                        ..LanguageConfig::default()
1076                    },
1077                    None,
1078                )
1079                .with_context_provider(Some(Arc::new(
1080                    ContextProviderWithTasks::new(TaskTemplates(vec![
1081                        TaskTemplate {
1082                            label: "Task without variables".to_string(),
1083                            command: "npm run clean".to_string(),
1084                            ..TaskTemplate::default()
1085                        },
1086                        TaskTemplate {
1087                            label: "TypeScript task from file $ZED_FILE".to_string(),
1088                            command: "npm run build".to_string(),
1089                            ..TaskTemplate::default()
1090                        },
1091                        TaskTemplate {
1092                            label: "Another task from file $ZED_FILE".to_string(),
1093                            command: "npm run lint".to_string(),
1094                            ..TaskTemplate::default()
1095                        },
1096                    ])),
1097                ))),
1098            ));
1099            language_registry.add(Arc::new(
1100                Language::new(
1101                    LanguageConfig {
1102                        name: "Rust".into(),
1103                        matcher: LanguageMatcher {
1104                            path_suffixes: vec!["rs".to_string()],
1105                            ..LanguageMatcher::default()
1106                        },
1107                        ..LanguageConfig::default()
1108                    },
1109                    None,
1110                )
1111                .with_context_provider(Some(Arc::new(
1112                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1113                        label: "Rust task".to_string(),
1114                        command: "cargo check".into(),
1115                        ..TaskTemplate::default()
1116                    }])),
1117                ))),
1118            ));
1119        });
1120        let (multi_workspace, cx) =
1121            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1122        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1123
1124        let _ts_file_1 = workspace
1125            .update_in(cx, |workspace, window, cx| {
1126                workspace.open_abs_path(
1127                    PathBuf::from(path!("/dir/a1.ts")),
1128                    OpenOptions {
1129                        visible: Some(OpenVisible::All),
1130                        ..Default::default()
1131                    },
1132                    window,
1133                    cx,
1134                )
1135            })
1136            .await
1137            .unwrap();
1138        let tasks_picker = open_spawn_tasks(&workspace, cx);
1139        assert_eq!(
1140            task_names(&tasks_picker, cx),
1141            vec![
1142                concat!("Another task from file ", path!("/dir/a1.ts")),
1143                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1144                "Task without variables",
1145            ],
1146            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1147        );
1148
1149        emulate_task_schedule(
1150            tasks_picker,
1151            &project,
1152            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1153            cx,
1154        );
1155
1156        let tasks_picker = open_spawn_tasks(&workspace, cx);
1157        assert_eq!(
1158            task_names(&tasks_picker, cx),
1159            vec![
1160                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1161                concat!("Another task from file ", path!("/dir/a1.ts")),
1162                "Task without variables",
1163            ],
1164            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1165            Tasks with the same labels and context are deduplicated."
1166        );
1167        tasks_picker.update(cx, |_, cx| {
1168            cx.emit(DismissEvent);
1169        });
1170        drop(tasks_picker);
1171        cx.executor().run_until_parked();
1172
1173        let _ts_file_2 = workspace
1174            .update_in(cx, |workspace, window, cx| {
1175                workspace.open_abs_path(
1176                    PathBuf::from(path!("/dir/a2.ts")),
1177                    OpenOptions {
1178                        visible: Some(OpenVisible::All),
1179                        ..Default::default()
1180                    },
1181                    window,
1182                    cx,
1183                )
1184            })
1185            .await
1186            .unwrap();
1187        let tasks_picker = open_spawn_tasks(&workspace, cx);
1188        assert_eq!(
1189            task_names(&tasks_picker, cx),
1190            vec![
1191                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1192                concat!("Another task from file ", path!("/dir/a2.ts")),
1193                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1194                "Task without variables",
1195            ],
1196            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1197        );
1198        tasks_picker.update(cx, |_, cx| {
1199            cx.emit(DismissEvent);
1200        });
1201        drop(tasks_picker);
1202        cx.executor().run_until_parked();
1203
1204        let _rs_file = workspace
1205            .update_in(cx, |workspace, window, cx| {
1206                workspace.open_abs_path(
1207                    PathBuf::from(path!("/dir/b.rs")),
1208                    OpenOptions {
1209                        visible: Some(OpenVisible::All),
1210                        ..Default::default()
1211                    },
1212                    window,
1213                    cx,
1214                )
1215            })
1216            .await
1217            .unwrap();
1218        let tasks_picker = open_spawn_tasks(&workspace, cx);
1219        assert_eq!(
1220            task_names(&tasks_picker, cx),
1221            vec!["Rust task"],
1222            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1223        );
1224
1225        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1226        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1227        let _ts_file_2 = workspace
1228            .update_in(cx, |workspace, window, cx| {
1229                workspace.open_abs_path(
1230                    PathBuf::from(path!("/dir/a2.ts")),
1231                    OpenOptions {
1232                        visible: Some(OpenVisible::All),
1233                        ..Default::default()
1234                    },
1235                    window,
1236                    cx,
1237                )
1238            })
1239            .await
1240            .unwrap();
1241        let tasks_picker = open_spawn_tasks(&workspace, cx);
1242        assert_eq!(
1243            task_names(&tasks_picker, cx),
1244            vec![
1245                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1246                concat!("Another task from file ", path!("/dir/a2.ts")),
1247                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1248                "Task without variables",
1249            ],
1250            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1251            same TS spawn history should be restored"
1252        );
1253    }
1254
1255    fn emulate_task_schedule(
1256        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1257        project: &Entity<Project>,
1258        scheduled_task_label: &str,
1259        cx: &mut VisualTestContext,
1260    ) {
1261        let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1262            tasks_picker
1263                .delegate
1264                .candidates
1265                .iter()
1266                .flatten()
1267                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1268                .cloned()
1269                .unwrap()
1270        });
1271        project.update(cx, |project, cx| {
1272            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1273                task_inventory.update(cx, |inventory, _| {
1274                    let (kind, task) = scheduled_task;
1275                    inventory.task_scheduled(kind, task);
1276                });
1277            }
1278        });
1279        tasks_picker.update(cx, |_, cx| {
1280            cx.emit(DismissEvent);
1281        });
1282        drop(tasks_picker);
1283        cx.executor().run_until_parked()
1284    }
1285
1286    fn open_spawn_tasks(
1287        workspace: &Entity<Workspace>,
1288        cx: &mut VisualTestContext,
1289    ) -> Entity<Picker<TasksModalDelegate>> {
1290        cx.dispatch_action(Spawn::modal());
1291        workspace.update(cx, |workspace, cx| {
1292            workspace
1293                .active_modal::<TasksModal>(cx)
1294                .expect("no task modal after `Spawn` action was dispatched")
1295                .read(cx)
1296                .picker
1297                .clone()
1298        })
1299    }
1300
1301    fn query(
1302        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1303        cx: &mut VisualTestContext,
1304    ) -> String {
1305        spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1306    }
1307
1308    fn task_names(
1309        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1310        cx: &mut VisualTestContext,
1311    ) -> Vec<String> {
1312        spawn_tasks.read_with(cx, |spawn_tasks, _| {
1313            spawn_tasks
1314                .delegate
1315                .matches
1316                .iter()
1317                .map(|hit| hit.string.clone())
1318                .collect::<Vec<_>>()
1319        })
1320    }
1321}