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> =
 365                    task.resolved_debug_adapter_config()
 366                else {
 367                    return;
 368                };
 369
 370                match &config.request {
 371                    DebugRequestType::Attach(attach_config)
 372                        if attach_config.process_id.is_none() =>
 373                    {
 374                        cx.emit(ShowAttachModal {
 375                            debug_config: config.clone(),
 376                        });
 377                        return;
 378                    }
 379                    _ => {
 380                        self.workspace
 381                            .update(cx, |workspace, cx| {
 382                                workspace.project().update(cx, |project, cx| {
 383                                    project
 384                                        .start_debug_session(config, cx)
 385                                        .detach_and_log_err(cx);
 386                                });
 387                            })
 388                            .ok();
 389                    }
 390                }
 391            }
 392            _ => {
 393                self.workspace
 394                    .update(cx, |workspace, cx| {
 395                        schedule_resolved_task(
 396                            workspace,
 397                            task_source_kind,
 398                            task,
 399                            omit_history_entry,
 400                            cx,
 401                        );
 402                    })
 403                    .ok();
 404            }
 405        };
 406
 407        cx.emit(DismissEvent);
 408    }
 409
 410    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
 411        cx.emit(DismissEvent);
 412    }
 413
 414    fn render_match(
 415        &self,
 416        ix: usize,
 417        selected: bool,
 418        window: &mut Window,
 419        cx: &mut Context<picker::Picker<Self>>,
 420    ) -> Option<Self::ListItem> {
 421        let candidates = self.candidates.as_ref()?;
 422        let hit = &self.matches[ix];
 423        let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
 424        let template = resolved_task.original_task();
 425        let display_label = resolved_task.display_label();
 426
 427        let mut tooltip_label_text = if display_label != &template.label {
 428            resolved_task.resolved_label.clone()
 429        } else {
 430            String::new()
 431        };
 432        if let Some(resolved) = resolved_task.resolved.as_ref() {
 433            if resolved.command_label != display_label
 434                && resolved.command_label != resolved_task.resolved_label
 435            {
 436                if !tooltip_label_text.trim().is_empty() {
 437                    tooltip_label_text.push('\n');
 438                }
 439                tooltip_label_text.push_str(&resolved.command_label);
 440            }
 441        }
 442        if template.tags.len() > 0 {
 443            tooltip_label_text.push('\n');
 444            tooltip_label_text.push_str(
 445                template
 446                    .tags
 447                    .iter()
 448                    .map(|tag| format!("\n#{}", tag))
 449                    .collect::<Vec<_>>()
 450                    .join("")
 451                    .as_str(),
 452            );
 453        }
 454        let tooltip_label = if tooltip_label_text.trim().is_empty() {
 455            None
 456        } else {
 457            Some(Tooltip::simple(tooltip_label_text, cx))
 458        };
 459
 460        let highlighted_location = HighlightedMatch {
 461            text: hit.string.clone(),
 462            highlight_positions: hit.positions.clone(),
 463            char_count: hit.string.chars().count(),
 464            color: Color::Default,
 465        };
 466        let icon = match source_kind {
 467            TaskSourceKind::Lsp(..) => Some(Icon::new(IconName::Bolt)),
 468            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
 469            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
 470            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
 471            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
 472                .get_icon_for_type(&name.to_lowercase(), cx)
 473                .map(Icon::from_path),
 474        }
 475        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
 476        let history_run_icon = if Some(ix) <= self.divider_index {
 477            Some(
 478                Icon::new(IconName::HistoryRerun)
 479                    .color(Color::Muted)
 480                    .size(IconSize::Small)
 481                    .into_any_element(),
 482            )
 483        } else {
 484            Some(
 485                v_flex()
 486                    .flex_none()
 487                    .size(IconSize::Small.rems())
 488                    .into_any_element(),
 489            )
 490        };
 491
 492        Some(
 493            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
 494                .inset(true)
 495                .start_slot::<Icon>(icon)
 496                .end_slot::<AnyElement>(
 497                    h_flex()
 498                        .gap_1()
 499                        .child(Label::new(truncate_and_trailoff(
 500                            &template
 501                                .tags
 502                                .iter()
 503                                .map(|tag| format!("#{}", tag))
 504                                .collect::<Vec<_>>()
 505                                .join(" "),
 506                            MAX_TAGS_LINE_LEN,
 507                        )))
 508                        .flex_none()
 509                        .child(history_run_icon.unwrap())
 510                        .into_any_element(),
 511                )
 512                .spacing(ListItemSpacing::Sparse)
 513                .when_some(tooltip_label, |list_item, item_label| {
 514                    list_item.tooltip(move |_, _| item_label.clone())
 515                })
 516                .map(|item| {
 517                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
 518                        || Some(ix) <= self.divider_index
 519                    {
 520                        let task_index = hit.candidate_id;
 521                        let delete_button = div().child(
 522                            IconButton::new("delete", IconName::Close)
 523                                .shape(IconButtonShape::Square)
 524                                .icon_color(Color::Muted)
 525                                .size(ButtonSize::None)
 526                                .icon_size(IconSize::XSmall)
 527                                .on_click(cx.listener(move |picker, _event, window, cx| {
 528                                    cx.stop_propagation();
 529                                    window.prevent_default();
 530
 531                                    picker.delegate.delete_previously_used(task_index, cx);
 532                                    picker.delegate.last_used_candidate_index = picker
 533                                        .delegate
 534                                        .last_used_candidate_index
 535                                        .unwrap_or(0)
 536                                        .checked_sub(1);
 537                                    picker.refresh(window, cx);
 538                                }))
 539                                .tooltip(|_, cx| {
 540                                    Tooltip::simple("Delete Previously Scheduled Task", cx)
 541                                }),
 542                        );
 543                        item.end_hover_slot(delete_button)
 544                    } else {
 545                        item
 546                    };
 547                    item
 548                })
 549                .toggle_state(selected)
 550                .child(highlighted_location.render(window, cx)),
 551        )
 552    }
 553
 554    fn confirm_completion(
 555        &mut self,
 556        _: String,
 557        _window: &mut Window,
 558        _: &mut Context<Picker<Self>>,
 559    ) -> Option<String> {
 560        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 561        let tasks = self.candidates.as_ref()?;
 562        let (_, task) = tasks.get(task_index)?;
 563        Some(task.resolved.as_ref()?.command_label.clone())
 564    }
 565
 566    fn confirm_input(
 567        &mut self,
 568        omit_history_entry: bool,
 569        _: &mut Window,
 570        cx: &mut Context<Picker<Self>>,
 571    ) {
 572        let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
 573            return;
 574        };
 575
 576        if let Some(TaskOverrides {
 577            reveal_target: Some(reveal_target),
 578        }) = self.task_overrides
 579        {
 580            if let Some(resolved_task) = &mut task.resolved {
 581                resolved_task.reveal_target = reveal_target;
 582            }
 583        }
 584        self.workspace
 585            .update(cx, |workspace, cx| {
 586                match task.task_type() {
 587                    TaskType::Script => schedule_resolved_task(
 588                        workspace,
 589                        task_source_kind,
 590                        task,
 591                        omit_history_entry,
 592                        cx,
 593                    ),
 594                    // todo(debugger): Should create a schedule_resolved_debug_task function
 595                    // This would allow users to access to debug history and other issues
 596                    TaskType::Debug(debug_args) => {
 597                        let Some(debug_config) = task.resolved_debug_adapter_config() else {
 598                            // todo(debugger) log an error, this should never happen
 599                            return;
 600                        };
 601
 602                        if debug_args.locator.is_some() {
 603                            schedule_resolved_task(
 604                                workspace,
 605                                task_source_kind,
 606                                task,
 607                                omit_history_entry,
 608                                cx,
 609                            );
 610                        } else {
 611                            workspace.project().update(cx, |project, cx| {
 612                                project
 613                                    .start_debug_session(debug_config, cx)
 614                                    .detach_and_log_err(cx);
 615                            });
 616                        }
 617                    }
 618                };
 619            })
 620            .ok();
 621        cx.emit(DismissEvent);
 622    }
 623
 624    fn separators_after_indices(&self) -> Vec<usize> {
 625        if let Some(i) = self.divider_index {
 626            vec![i]
 627        } else {
 628            Vec::new()
 629        }
 630    }
 631    fn render_footer(
 632        &self,
 633        window: &mut Window,
 634        cx: &mut Context<Picker<Self>>,
 635    ) -> Option<gpui::AnyElement> {
 636        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 637        let current_modifiers = window.modifiers();
 638        let left_button = if self
 639            .task_store
 640            .read(cx)
 641            .task_inventory()?
 642            .read(cx)
 643            .last_scheduled_task(None)
 644            .is_some()
 645        {
 646            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 647        } else {
 648            None
 649        };
 650        Some(
 651            h_flex()
 652                .w_full()
 653                .h_8()
 654                .p_2()
 655                .justify_between()
 656                .rounded_b_sm()
 657                .bg(cx.theme().colors().ghost_element_selected)
 658                .border_t_1()
 659                .border_color(cx.theme().colors().border_variant)
 660                .child(
 661                    left_button
 662                        .map(|(label, action)| {
 663                            let keybind = KeyBinding::for_action(&*action, window, cx);
 664
 665                            Button::new("edit-current-task", label)
 666                                .label_size(LabelSize::Small)
 667                                .when_some(keybind, |this, keybind| this.key_binding(keybind))
 668                                .on_click(move |_, window, cx| {
 669                                    window.dispatch_action(action.boxed_clone(), cx);
 670                                })
 671                                .into_any_element()
 672                        })
 673                        .unwrap_or_else(|| h_flex().into_any_element()),
 674                )
 675                .map(|this| {
 676                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 677                    {
 678                        let action = picker::ConfirmInput {
 679                            secondary: current_modifiers.secondary(),
 680                        }
 681                        .boxed_clone();
 682                        this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
 683                            let spawn_oneshot_label = if current_modifiers.secondary() {
 684                                "Spawn Oneshot Without History"
 685                            } else {
 686                                "Spawn Oneshot"
 687                            };
 688
 689                            Button::new("spawn-onehshot", spawn_oneshot_label)
 690                                .label_size(LabelSize::Small)
 691                                .key_binding(keybind)
 692                                .on_click(move |_, window, cx| {
 693                                    window.dispatch_action(action.boxed_clone(), cx)
 694                                })
 695                        }))
 696                    } else if current_modifiers.secondary() {
 697                        this.children(
 698                            KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
 699                                |keybind| {
 700                                    let label = if is_recent_selected {
 701                                        "Rerun Without History"
 702                                    } else {
 703                                        "Spawn Without History"
 704                                    };
 705                                    Button::new("spawn", label)
 706                                        .label_size(LabelSize::Small)
 707                                        .key_binding(keybind)
 708                                        .on_click(move |_, window, cx| {
 709                                            window.dispatch_action(
 710                                                menu::SecondaryConfirm.boxed_clone(),
 711                                                cx,
 712                                            )
 713                                        })
 714                                },
 715                            ),
 716                        )
 717                    } else {
 718                        this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
 719                            |keybind| {
 720                                let run_entry_label =
 721                                    if is_recent_selected { "Rerun" } else { "Spawn" };
 722
 723                                Button::new("spawn", run_entry_label)
 724                                    .label_size(LabelSize::Small)
 725                                    .key_binding(keybind)
 726                                    .on_click(|_, window, cx| {
 727                                        window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 728                                    })
 729                            },
 730                        ))
 731                    }
 732                })
 733                .into_any_element(),
 734        )
 735    }
 736}
 737
 738fn string_match_candidates<'a>(
 739    candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 740    task_modal_type: TaskModal,
 741) -> Vec<StringMatchCandidate> {
 742    candidates
 743        .into_iter()
 744        .enumerate()
 745        .filter(|(_, (_, candidate))| match candidate.task_type() {
 746            TaskType::Script => task_modal_type == TaskModal::ScriptModal,
 747            TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal,
 748        })
 749        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 750        .collect()
 751}
 752
 753#[cfg(test)]
 754mod tests {
 755    use std::{path::PathBuf, sync::Arc};
 756
 757    use editor::Editor;
 758    use gpui::{TestAppContext, VisualTestContext};
 759    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 760    use project::{ContextProviderWithTasks, FakeFs, Project};
 761    use serde_json::json;
 762    use task::TaskTemplates;
 763    use util::path;
 764    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 765
 766    use crate::{modal::Spawn, tests::init_test};
 767
 768    use super::*;
 769
 770    #[gpui::test]
 771    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 772        init_test(cx);
 773        let fs = FakeFs::new(cx.executor());
 774        fs.insert_tree(
 775            path!("/dir"),
 776            json!({
 777                ".zed": {
 778                    "tasks.json": r#"[
 779                        {
 780                            "label": "example task",
 781                            "command": "echo",
 782                            "args": ["4"]
 783                        },
 784                        {
 785                            "label": "another one",
 786                            "command": "echo",
 787                            "args": ["55"]
 788                        },
 789                    ]"#,
 790                },
 791                "a.ts": "a"
 792            }),
 793        )
 794        .await;
 795
 796        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 797        let (workspace, cx) =
 798            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
 799
 800        let tasks_picker = open_spawn_tasks(&workspace, cx);
 801        assert_eq!(
 802            query(&tasks_picker, cx),
 803            "",
 804            "Initial query should be empty"
 805        );
 806        assert_eq!(
 807            task_names(&tasks_picker, cx),
 808            vec!["another one", "example task"],
 809            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 810        );
 811        drop(tasks_picker);
 812
 813        let _ = workspace
 814            .update_in(cx, |workspace, window, cx| {
 815                workspace.open_abs_path(
 816                    PathBuf::from(path!("/dir/a.ts")),
 817                    OpenOptions {
 818                        visible: Some(OpenVisible::All),
 819                        ..Default::default()
 820                    },
 821                    window,
 822                    cx,
 823                )
 824            })
 825            .await
 826            .unwrap();
 827        let tasks_picker = open_spawn_tasks(&workspace, cx);
 828        assert_eq!(
 829            task_names(&tasks_picker, cx),
 830            vec!["another one", "example task"],
 831            "Initial tasks should be listed in alphabetical order"
 832        );
 833
 834        let query_str = "tas";
 835        cx.simulate_input(query_str);
 836        assert_eq!(query(&tasks_picker, cx), query_str);
 837        assert_eq!(
 838            task_names(&tasks_picker, cx),
 839            vec!["example task"],
 840            "Only one task should match the query {query_str}"
 841        );
 842
 843        cx.dispatch_action(picker::ConfirmCompletion);
 844        assert_eq!(
 845            query(&tasks_picker, cx),
 846            "echo 4",
 847            "Query should be set to the selected task's command"
 848        );
 849        assert_eq!(
 850            task_names(&tasks_picker, cx),
 851            Vec::<String>::new(),
 852            "No task should be listed"
 853        );
 854        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 855
 856        let tasks_picker = open_spawn_tasks(&workspace, cx);
 857        assert_eq!(
 858            query(&tasks_picker, cx),
 859            "",
 860            "Query should be reset after confirming"
 861        );
 862        assert_eq!(
 863            task_names(&tasks_picker, cx),
 864            vec!["echo 4", "another one", "example task"],
 865            "New oneshot task should be listed first"
 866        );
 867
 868        let query_str = "echo 4";
 869        cx.simulate_input(query_str);
 870        assert_eq!(query(&tasks_picker, cx), query_str);
 871        assert_eq!(
 872            task_names(&tasks_picker, cx),
 873            vec!["echo 4"],
 874            "New oneshot should match custom command query"
 875        );
 876
 877        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 878        let tasks_picker = open_spawn_tasks(&workspace, cx);
 879        assert_eq!(
 880            query(&tasks_picker, cx),
 881            "",
 882            "Query should be reset after confirming"
 883        );
 884        assert_eq!(
 885            task_names(&tasks_picker, cx),
 886            vec![query_str, "another one", "example task"],
 887            "Last recently used one show task should be listed first"
 888        );
 889
 890        cx.dispatch_action(picker::ConfirmCompletion);
 891        assert_eq!(
 892            query(&tasks_picker, cx),
 893            query_str,
 894            "Query should be set to the custom task's name"
 895        );
 896        assert_eq!(
 897            task_names(&tasks_picker, cx),
 898            vec![query_str],
 899            "Only custom task should be listed"
 900        );
 901
 902        let query_str = "0";
 903        cx.simulate_input(query_str);
 904        assert_eq!(query(&tasks_picker, cx), "echo 40");
 905        assert_eq!(
 906            task_names(&tasks_picker, cx),
 907            Vec::<String>::new(),
 908            "New oneshot should not match any command query"
 909        );
 910
 911        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 912        let tasks_picker = open_spawn_tasks(&workspace, cx);
 913        assert_eq!(
 914            query(&tasks_picker, cx),
 915            "",
 916            "Query should be reset after confirming"
 917        );
 918        assert_eq!(
 919            task_names(&tasks_picker, cx),
 920            vec!["echo 4", "another one", "example task"],
 921            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 922        );
 923
 924        cx.dispatch_action(Spawn::ByName {
 925            task_name: "example task".to_string(),
 926            reveal_target: None,
 927        });
 928        let tasks_picker = workspace.update(cx, |workspace, cx| {
 929            workspace
 930                .active_modal::<TasksModal>(cx)
 931                .unwrap()
 932                .read(cx)
 933                .picker
 934                .clone()
 935        });
 936        assert_eq!(
 937            task_names(&tasks_picker, cx),
 938            vec!["echo 4", "another one", "example task"],
 939        );
 940    }
 941
 942    #[gpui::test]
 943    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 944        init_test(cx);
 945        let fs = FakeFs::new(cx.executor());
 946        fs.insert_tree(
 947            path!("/dir"),
 948            json!({
 949                ".zed": {
 950                    "tasks.json": r#"[
 951                        {
 952                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 953                            "command": "echo",
 954                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 955                        },
 956                        {
 957                            "label": "opened now: $ZED_WORKTREE_ROOT",
 958                            "command": "echo",
 959                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 960                        }
 961                    ]"#,
 962                },
 963                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 964                "file_with.odd_extension": "b",
 965            }),
 966        )
 967        .await;
 968
 969        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 970        let (workspace, cx) =
 971            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 972
 973        let tasks_picker = open_spawn_tasks(&workspace, cx);
 974        assert_eq!(
 975            task_names(&tasks_picker, cx),
 976            vec![concat!("opened now: ", path!("/dir")).to_string()],
 977            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 978        );
 979        tasks_picker.update(cx, |_, cx| {
 980            cx.emit(DismissEvent);
 981        });
 982        drop(tasks_picker);
 983        cx.executor().run_until_parked();
 984
 985        let _ = workspace
 986            .update_in(cx, |workspace, window, cx| {
 987                workspace.open_abs_path(
 988                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 989                    OpenOptions {
 990                        visible: Some(OpenVisible::All),
 991                        ..Default::default()
 992                    },
 993                    window,
 994                    cx,
 995                )
 996            })
 997            .await
 998            .unwrap();
 999        cx.executor().run_until_parked();
1000        let tasks_picker = open_spawn_tasks(&workspace, cx);
1001        assert_eq!(
1002            task_names(&tasks_picker, cx),
1003            vec![
1004                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
1005                concat!("opened now: ", path!("/dir")).to_string(),
1006            ],
1007            "Second opened buffer should fill the context, labels should be trimmed if long enough"
1008        );
1009        tasks_picker.update(cx, |_, cx| {
1010            cx.emit(DismissEvent);
1011        });
1012        drop(tasks_picker);
1013        cx.executor().run_until_parked();
1014
1015        let second_item = workspace
1016            .update_in(cx, |workspace, window, cx| {
1017                workspace.open_abs_path(
1018                    PathBuf::from(path!("/dir/file_without_extension")),
1019                    OpenOptions {
1020                        visible: Some(OpenVisible::All),
1021                        ..Default::default()
1022                    },
1023                    window,
1024                    cx,
1025                )
1026            })
1027            .await
1028            .unwrap();
1029
1030        let editor = cx
1031            .update(|_window, cx| second_item.act_as::<Editor>(cx))
1032            .unwrap();
1033        editor.update_in(cx, |editor, window, cx| {
1034            editor.change_selections(None, window, cx, |s| {
1035                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1036            })
1037        });
1038        cx.executor().run_until_parked();
1039        let tasks_picker = open_spawn_tasks(&workspace, cx);
1040        assert_eq!(
1041            task_names(&tasks_picker, cx),
1042            vec![
1043                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1044                concat!("opened now: ", path!("/dir")).to_string(),
1045            ],
1046            "Opened buffer should fill the context, labels should be trimmed if long enough"
1047        );
1048        tasks_picker.update(cx, |_, cx| {
1049            cx.emit(DismissEvent);
1050        });
1051        drop(tasks_picker);
1052        cx.executor().run_until_parked();
1053    }
1054
1055    #[gpui::test]
1056    async fn test_language_task_filtering(cx: &mut TestAppContext) {
1057        init_test(cx);
1058        let fs = FakeFs::new(cx.executor());
1059        fs.insert_tree(
1060            path!("/dir"),
1061            json!({
1062                "a1.ts": "// a1",
1063                "a2.ts": "// a2",
1064                "b.rs": "// b",
1065            }),
1066        )
1067        .await;
1068
1069        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1070        project.read_with(cx, |project, _| {
1071            let language_registry = project.languages();
1072            language_registry.add(Arc::new(
1073                Language::new(
1074                    LanguageConfig {
1075                        name: "TypeScript".into(),
1076                        matcher: LanguageMatcher {
1077                            path_suffixes: vec!["ts".to_string()],
1078                            ..LanguageMatcher::default()
1079                        },
1080                        ..LanguageConfig::default()
1081                    },
1082                    None,
1083                )
1084                .with_context_provider(Some(Arc::new(
1085                    ContextProviderWithTasks::new(TaskTemplates(vec![
1086                        TaskTemplate {
1087                            label: "Task without variables".to_string(),
1088                            command: "npm run clean".to_string(),
1089                            ..TaskTemplate::default()
1090                        },
1091                        TaskTemplate {
1092                            label: "TypeScript task from file $ZED_FILE".to_string(),
1093                            command: "npm run build".to_string(),
1094                            ..TaskTemplate::default()
1095                        },
1096                        TaskTemplate {
1097                            label: "Another task from file $ZED_FILE".to_string(),
1098                            command: "npm run lint".to_string(),
1099                            ..TaskTemplate::default()
1100                        },
1101                    ])),
1102                ))),
1103            ));
1104            language_registry.add(Arc::new(
1105                Language::new(
1106                    LanguageConfig {
1107                        name: "Rust".into(),
1108                        matcher: LanguageMatcher {
1109                            path_suffixes: vec!["rs".to_string()],
1110                            ..LanguageMatcher::default()
1111                        },
1112                        ..LanguageConfig::default()
1113                    },
1114                    None,
1115                )
1116                .with_context_provider(Some(Arc::new(
1117                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1118                        label: "Rust task".to_string(),
1119                        command: "cargo check".into(),
1120                        ..TaskTemplate::default()
1121                    }])),
1122                ))),
1123            ));
1124        });
1125        let (workspace, cx) =
1126            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1127
1128        let _ts_file_1 = workspace
1129            .update_in(cx, |workspace, window, cx| {
1130                workspace.open_abs_path(
1131                    PathBuf::from(path!("/dir/a1.ts")),
1132                    OpenOptions {
1133                        visible: Some(OpenVisible::All),
1134                        ..Default::default()
1135                    },
1136                    window,
1137                    cx,
1138                )
1139            })
1140            .await
1141            .unwrap();
1142        let tasks_picker = open_spawn_tasks(&workspace, cx);
1143        assert_eq!(
1144            task_names(&tasks_picker, cx),
1145            vec![
1146                concat!("Another task from file ", path!("/dir/a1.ts")),
1147                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1148                "Task without variables",
1149            ],
1150            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1151        );
1152
1153        emulate_task_schedule(
1154            tasks_picker,
1155            &project,
1156            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1157            cx,
1158        );
1159
1160        let tasks_picker = open_spawn_tasks(&workspace, cx);
1161        assert_eq!(
1162            task_names(&tasks_picker, cx),
1163            vec![
1164                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1165                concat!("Another task from file ", path!("/dir/a1.ts")),
1166                "Task without variables",
1167            ],
1168            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1169            Tasks with the same labels and context are deduplicated."
1170        );
1171        tasks_picker.update(cx, |_, cx| {
1172            cx.emit(DismissEvent);
1173        });
1174        drop(tasks_picker);
1175        cx.executor().run_until_parked();
1176
1177        let _ts_file_2 = workspace
1178            .update_in(cx, |workspace, window, cx| {
1179                workspace.open_abs_path(
1180                    PathBuf::from(path!("/dir/a2.ts")),
1181                    OpenOptions {
1182                        visible: Some(OpenVisible::All),
1183                        ..Default::default()
1184                    },
1185                    window,
1186                    cx,
1187                )
1188            })
1189            .await
1190            .unwrap();
1191        let tasks_picker = open_spawn_tasks(&workspace, cx);
1192        assert_eq!(
1193            task_names(&tasks_picker, cx),
1194            vec![
1195                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1196                concat!("Another task from file ", path!("/dir/a2.ts")),
1197                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1198                "Task without variables",
1199            ],
1200            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1201        );
1202        tasks_picker.update(cx, |_, cx| {
1203            cx.emit(DismissEvent);
1204        });
1205        drop(tasks_picker);
1206        cx.executor().run_until_parked();
1207
1208        let _rs_file = workspace
1209            .update_in(cx, |workspace, window, cx| {
1210                workspace.open_abs_path(
1211                    PathBuf::from(path!("/dir/b.rs")),
1212                    OpenOptions {
1213                        visible: Some(OpenVisible::All),
1214                        ..Default::default()
1215                    },
1216                    window,
1217                    cx,
1218                )
1219            })
1220            .await
1221            .unwrap();
1222        let tasks_picker = open_spawn_tasks(&workspace, cx);
1223        assert_eq!(
1224            task_names(&tasks_picker, cx),
1225            vec!["Rust task"],
1226            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1227        );
1228
1229        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1230        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1231        let _ts_file_2 = workspace
1232            .update_in(cx, |workspace, window, cx| {
1233                workspace.open_abs_path(
1234                    PathBuf::from(path!("/dir/a2.ts")),
1235                    OpenOptions {
1236                        visible: Some(OpenVisible::All),
1237                        ..Default::default()
1238                    },
1239                    window,
1240                    cx,
1241                )
1242            })
1243            .await
1244            .unwrap();
1245        let tasks_picker = open_spawn_tasks(&workspace, cx);
1246        assert_eq!(
1247            task_names(&tasks_picker, cx),
1248            vec![
1249                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1250                concat!("Another task from file ", path!("/dir/a2.ts")),
1251                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1252                "Task without variables",
1253            ],
1254            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1255            same TS spawn history should be restored"
1256        );
1257    }
1258
1259    fn emulate_task_schedule(
1260        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1261        project: &Entity<Project>,
1262        scheduled_task_label: &str,
1263        cx: &mut VisualTestContext,
1264    ) {
1265        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1266            tasks_picker
1267                .delegate
1268                .candidates
1269                .iter()
1270                .flatten()
1271                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1272                .cloned()
1273                .unwrap()
1274        });
1275        project.update(cx, |project, cx| {
1276            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1277                task_inventory.update(cx, |inventory, _| {
1278                    let (kind, task) = scheduled_task;
1279                    inventory.task_scheduled(kind, task);
1280                });
1281            }
1282        });
1283        tasks_picker.update(cx, |_, cx| {
1284            cx.emit(DismissEvent);
1285        });
1286        drop(tasks_picker);
1287        cx.executor().run_until_parked()
1288    }
1289
1290    fn open_spawn_tasks(
1291        workspace: &Entity<Workspace>,
1292        cx: &mut VisualTestContext,
1293    ) -> Entity<Picker<TasksModalDelegate>> {
1294        cx.dispatch_action(Spawn::modal());
1295        workspace.update(cx, |workspace, cx| {
1296            workspace
1297                .active_modal::<TasksModal>(cx)
1298                .expect("no task modal after `Spawn` action was dispatched")
1299                .read(cx)
1300                .picker
1301                .clone()
1302        })
1303    }
1304
1305    fn query(
1306        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1307        cx: &mut VisualTestContext,
1308    ) -> String {
1309        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1310    }
1311
1312    fn task_names(
1313        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1314        cx: &mut VisualTestContext,
1315    ) -> Vec<String> {
1316        spawn_tasks.update(cx, |spawn_tasks, _| {
1317            spawn_tasks
1318                .delegate
1319                .matches
1320                .iter()
1321                .map(|hit| hit.string.clone())
1322                .collect::<Vec<_>>()
1323        })
1324    }
1325}