modal.rs

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