modal.rs

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