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