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