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| {
 570                                    Tooltip::simple("Delete Previously Scheduled Task", cx)
 571                                }),
 572                        );
 573                        item.end_hover_slot(delete_button)
 574                    } else {
 575                        item
 576                    }
 577                })
 578                .toggle_state(selected)
 579                .child(highlighted_location.render(window, cx)),
 580        )
 581    }
 582
 583    fn confirm_completion(
 584        &mut self,
 585        _: String,
 586        _window: &mut Window,
 587        _: &mut Context<Picker<Self>>,
 588    ) -> Option<String> {
 589        let task_index = self.matches.get(self.selected_index())?.candidate_id;
 590        let tasks = self.candidates.as_ref()?;
 591        let (_, task) = tasks.get(task_index)?;
 592        Some(task.resolved.command_label.clone())
 593    }
 594
 595    fn confirm_input(
 596        &mut self,
 597        omit_history_entry: bool,
 598        window: &mut Window,
 599        cx: &mut Context<Picker<Self>>,
 600    ) {
 601        let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
 602            return;
 603        };
 604
 605        if let Some(TaskOverrides {
 606            reveal_target: Some(reveal_target),
 607        }) = self.task_overrides
 608        {
 609            task.resolved.reveal_target = reveal_target;
 610        }
 611        self.workspace
 612            .update(cx, |workspace, cx| {
 613                workspace.schedule_resolved_task(
 614                    task_source_kind,
 615                    task,
 616                    omit_history_entry,
 617                    window,
 618                    cx,
 619                )
 620            })
 621            .ok();
 622        cx.emit(DismissEvent);
 623    }
 624
 625    fn separators_after_indices(&self) -> Vec<usize> {
 626        if let Some(i) = self.divider_index {
 627            vec![i]
 628        } else {
 629            Vec::new()
 630        }
 631    }
 632
 633    fn render_footer(
 634        &self,
 635        window: &mut Window,
 636        cx: &mut Context<Picker<Self>>,
 637    ) -> Option<gpui::AnyElement> {
 638        let is_recent_selected = self.divider_index >= Some(self.selected_index);
 639        let current_modifiers = window.modifiers();
 640        let left_button = if self
 641            .task_store
 642            .read(cx)
 643            .task_inventory()?
 644            .read(cx)
 645            .last_scheduled_task(None)
 646            .is_some()
 647        {
 648            Some(("Rerun Last Task", Rerun::default().boxed_clone()))
 649        } else {
 650            None
 651        };
 652        Some(
 653            h_flex()
 654                .w_full()
 655                .p_1p5()
 656                .justify_between()
 657                .border_t_1()
 658                .border_color(cx.theme().colors().border_variant)
 659                .child(
 660                    left_button
 661                        .map(|(label, action)| {
 662                            let keybind = KeyBinding::for_action(&*action, cx);
 663
 664                            Button::new("edit-current-task", label)
 665                                .key_binding(keybind)
 666                                .on_click(move |_, window, cx| {
 667                                    window.dispatch_action(action.boxed_clone(), cx);
 668                                })
 669                                .into_any_element()
 670                        })
 671                        .unwrap_or_else(|| h_flex().into_any_element()),
 672                )
 673                .map(|this| {
 674                    if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
 675                    {
 676                        let action = picker::ConfirmInput {
 677                            secondary: current_modifiers.secondary(),
 678                        }
 679                        .boxed_clone();
 680                        this.child({
 681                            let spawn_oneshot_label = if current_modifiers.secondary() {
 682                                "Spawn Oneshot Without History"
 683                            } else {
 684                                "Spawn Oneshot"
 685                            };
 686
 687                            Button::new("spawn-onehshot", spawn_oneshot_label)
 688                                .key_binding(KeyBinding::for_action(&*action, cx))
 689                                .on_click(move |_, window, cx| {
 690                                    window.dispatch_action(action.boxed_clone(), cx)
 691                                })
 692                        })
 693                    } else if current_modifiers.secondary() {
 694                        this.child({
 695                            let label = if is_recent_selected {
 696                                "Rerun Without History"
 697                            } else {
 698                                "Spawn Without History"
 699                            };
 700                            Button::new("spawn", label)
 701                                .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
 702                                .on_click(move |_, window, cx| {
 703                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
 704                                })
 705                        })
 706                    } else {
 707                        this.child({
 708                            let run_entry_label =
 709                                if is_recent_selected { "Rerun" } else { "Spawn" };
 710
 711                            Button::new("spawn", run_entry_label)
 712                                .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
 713                                .on_click(|_, window, cx| {
 714                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx);
 715                                })
 716                        })
 717                    }
 718                })
 719                .into_any_element(),
 720        )
 721    }
 722}
 723
 724fn string_match_candidates<'a>(
 725    candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
 726) -> Vec<StringMatchCandidate> {
 727    candidates
 728        .into_iter()
 729        .enumerate()
 730        .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
 731        .collect()
 732}
 733
 734#[cfg(test)]
 735mod tests {
 736    use std::{path::PathBuf, sync::Arc};
 737
 738    use editor::{Editor, SelectionEffects};
 739    use gpui::{TestAppContext, VisualTestContext};
 740    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 741    use project::{ContextProviderWithTasks, FakeFs, Project};
 742    use serde_json::json;
 743    use task::TaskTemplates;
 744    use util::path;
 745    use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible};
 746
 747    use crate::{modal::Spawn, tests::init_test};
 748
 749    use super::*;
 750
 751    #[gpui::test]
 752    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 753        init_test(cx);
 754        let fs = FakeFs::new(cx.executor());
 755        fs.insert_tree(
 756            path!("/dir"),
 757            json!({
 758                ".zed": {
 759                    "tasks.json": r#"[
 760                        {
 761                            "label": "example task",
 762                            "command": "echo",
 763                            "args": ["4"]
 764                        },
 765                        {
 766                            "label": "another one",
 767                            "command": "echo",
 768                            "args": ["55"]
 769                        },
 770                    ]"#,
 771                },
 772                "a.ts": "a"
 773            }),
 774        )
 775        .await;
 776
 777        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 778        let (multi_workspace, cx) =
 779            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 780        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 781
 782        let tasks_picker = open_spawn_tasks(&workspace, cx);
 783        assert_eq!(
 784            query(&tasks_picker, cx),
 785            "",
 786            "Initial query should be empty"
 787        );
 788        assert_eq!(
 789            task_names(&tasks_picker, cx),
 790            vec!["another one", "example task"],
 791            "With no global tasks and no open item, a single worktree should be used and its tasks listed"
 792        );
 793        drop(tasks_picker);
 794
 795        let _ = workspace
 796            .update_in(cx, |workspace, window, cx| {
 797                workspace.open_abs_path(
 798                    PathBuf::from(path!("/dir/a.ts")),
 799                    OpenOptions {
 800                        visible: Some(OpenVisible::All),
 801                        ..Default::default()
 802                    },
 803                    window,
 804                    cx,
 805                )
 806            })
 807            .await
 808            .unwrap();
 809        let tasks_picker = open_spawn_tasks(&workspace, cx);
 810        assert_eq!(
 811            task_names(&tasks_picker, cx),
 812            vec!["another one", "example task"],
 813            "Initial tasks should be listed in alphabetical order"
 814        );
 815
 816        let query_str = "tas";
 817        cx.simulate_input(query_str);
 818        assert_eq!(query(&tasks_picker, cx), query_str);
 819        assert_eq!(
 820            task_names(&tasks_picker, cx),
 821            vec!["example task"],
 822            "Only one task should match the query {query_str}"
 823        );
 824
 825        cx.dispatch_action(picker::ConfirmCompletion);
 826        assert_eq!(
 827            query(&tasks_picker, cx),
 828            "echo 4",
 829            "Query should be set to the selected task's command"
 830        );
 831        assert_eq!(
 832            task_names(&tasks_picker, cx),
 833            Vec::<String>::new(),
 834            "No task should be listed"
 835        );
 836        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 837
 838        let tasks_picker = open_spawn_tasks(&workspace, cx);
 839        assert_eq!(
 840            query(&tasks_picker, cx),
 841            "",
 842            "Query should be reset after confirming"
 843        );
 844        assert_eq!(
 845            task_names(&tasks_picker, cx),
 846            vec!["echo 4", "another one", "example task"],
 847            "New oneshot task should be listed first"
 848        );
 849
 850        let query_str = "echo 4";
 851        cx.simulate_input(query_str);
 852        assert_eq!(query(&tasks_picker, cx), query_str);
 853        assert_eq!(
 854            task_names(&tasks_picker, cx),
 855            vec!["echo 4"],
 856            "New oneshot should match custom command query"
 857        );
 858
 859        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 860        let tasks_picker = open_spawn_tasks(&workspace, cx);
 861        assert_eq!(
 862            query(&tasks_picker, cx),
 863            "",
 864            "Query should be reset after confirming"
 865        );
 866        assert_eq!(
 867            task_names(&tasks_picker, cx),
 868            vec![query_str, "another one", "example task"],
 869            "Last recently used one show task should be listed first"
 870        );
 871
 872        cx.dispatch_action(picker::ConfirmCompletion);
 873        assert_eq!(
 874            query(&tasks_picker, cx),
 875            query_str,
 876            "Query should be set to the custom task's name"
 877        );
 878        assert_eq!(
 879            task_names(&tasks_picker, cx),
 880            vec![query_str],
 881            "Only custom task should be listed"
 882        );
 883
 884        let query_str = "0";
 885        cx.simulate_input(query_str);
 886        assert_eq!(query(&tasks_picker, cx), "echo 40");
 887        assert_eq!(
 888            task_names(&tasks_picker, cx),
 889            Vec::<String>::new(),
 890            "New oneshot should not match any command query"
 891        );
 892
 893        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 894        let tasks_picker = open_spawn_tasks(&workspace, cx);
 895        assert_eq!(
 896            query(&tasks_picker, cx),
 897            "",
 898            "Query should be reset after confirming"
 899        );
 900        assert_eq!(
 901            task_names(&tasks_picker, cx),
 902            vec!["echo 4", "another one", "example task"],
 903            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 904        );
 905
 906        cx.dispatch_action(Spawn::ByName {
 907            task_name: "example task".to_string(),
 908            reveal_target: None,
 909        });
 910        let tasks_picker = workspace.update(cx, |workspace, cx| {
 911            workspace
 912                .active_modal::<TasksModal>(cx)
 913                .unwrap()
 914                .read(cx)
 915                .picker
 916                .clone()
 917        });
 918        assert_eq!(
 919            task_names(&tasks_picker, cx),
 920            vec!["echo 4", "another one", "example task"],
 921        );
 922    }
 923
 924    #[gpui::test]
 925    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 926        init_test(cx);
 927        let fs = FakeFs::new(cx.executor());
 928        fs.insert_tree(
 929            path!("/dir"),
 930            json!({
 931                ".zed": {
 932                    "tasks.json": r#"[
 933                        {
 934                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 935                            "command": "echo",
 936                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 937                        },
 938                        {
 939                            "label": "opened now: $ZED_WORKTREE_ROOT",
 940                            "command": "echo",
 941                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 942                        }
 943                    ]"#,
 944                },
 945                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 946                "file_with.odd_extension": "b",
 947            }),
 948        )
 949        .await;
 950
 951        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 952        let (multi_workspace, cx) =
 953            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
 954        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 955
 956        let tasks_picker = open_spawn_tasks(&workspace, cx);
 957        assert_eq!(
 958            task_names(&tasks_picker, cx),
 959            vec![concat!("opened now: ", path!("/dir")).to_string()],
 960            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 961        );
 962        tasks_picker.update(cx, |_, cx| {
 963            cx.emit(DismissEvent);
 964        });
 965        drop(tasks_picker);
 966        cx.executor().run_until_parked();
 967
 968        let _ = workspace
 969            .update_in(cx, |workspace, window, cx| {
 970                workspace.open_abs_path(
 971                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 972                    OpenOptions {
 973                        visible: Some(OpenVisible::All),
 974                        ..Default::default()
 975                    },
 976                    window,
 977                    cx,
 978                )
 979            })
 980            .await
 981            .unwrap();
 982        cx.executor().run_until_parked();
 983        let tasks_picker = open_spawn_tasks(&workspace, cx);
 984        assert_eq!(
 985            task_names(&tasks_picker, cx),
 986            vec![
 987                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 988                concat!("opened now: ", path!("/dir")).to_string(),
 989            ],
 990            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 991        );
 992        tasks_picker.update(cx, |_, cx| {
 993            cx.emit(DismissEvent);
 994        });
 995        drop(tasks_picker);
 996        cx.executor().run_until_parked();
 997
 998        let second_item = workspace
 999            .update_in(cx, |workspace, window, cx| {
1000                workspace.open_abs_path(
1001                    PathBuf::from(path!("/dir/file_without_extension")),
1002                    OpenOptions {
1003                        visible: Some(OpenVisible::All),
1004                        ..Default::default()
1005                    },
1006                    window,
1007                    cx,
1008                )
1009            })
1010            .await
1011            .unwrap();
1012
1013        let editor = cx
1014            .update(|_window, cx| second_item.act_as::<Editor>(cx))
1015            .unwrap();
1016        editor.update_in(cx, |editor, window, cx| {
1017            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1018                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1019            })
1020        });
1021        cx.executor().run_until_parked();
1022        let tasks_picker = open_spawn_tasks(&workspace, cx);
1023        assert_eq!(
1024            task_names(&tasks_picker, cx),
1025            vec![
1026                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1027                concat!("opened now: ", path!("/dir")).to_string(),
1028            ],
1029            "Opened buffer should fill the context, labels should be trimmed if long enough"
1030        );
1031        tasks_picker.update(cx, |_, cx| {
1032            cx.emit(DismissEvent);
1033        });
1034        drop(tasks_picker);
1035        cx.executor().run_until_parked();
1036    }
1037
1038    #[gpui::test]
1039    async fn test_language_task_filtering(cx: &mut TestAppContext) {
1040        init_test(cx);
1041        let fs = FakeFs::new(cx.executor());
1042        fs.insert_tree(
1043            path!("/dir"),
1044            json!({
1045                "a1.ts": "// a1",
1046                "a2.ts": "// a2",
1047                "b.rs": "// b",
1048            }),
1049        )
1050        .await;
1051
1052        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1053        project.read_with(cx, |project, _| {
1054            let language_registry = project.languages();
1055            language_registry.add(Arc::new(
1056                Language::new(
1057                    LanguageConfig {
1058                        name: "TypeScript".into(),
1059                        matcher: LanguageMatcher {
1060                            path_suffixes: vec!["ts".to_string()],
1061                            ..LanguageMatcher::default()
1062                        },
1063                        ..LanguageConfig::default()
1064                    },
1065                    None,
1066                )
1067                .with_context_provider(Some(Arc::new(
1068                    ContextProviderWithTasks::new(TaskTemplates(vec![
1069                        TaskTemplate {
1070                            label: "Task without variables".to_string(),
1071                            command: "npm run clean".to_string(),
1072                            ..TaskTemplate::default()
1073                        },
1074                        TaskTemplate {
1075                            label: "TypeScript task from file $ZED_FILE".to_string(),
1076                            command: "npm run build".to_string(),
1077                            ..TaskTemplate::default()
1078                        },
1079                        TaskTemplate {
1080                            label: "Another task from file $ZED_FILE".to_string(),
1081                            command: "npm run lint".to_string(),
1082                            ..TaskTemplate::default()
1083                        },
1084                    ])),
1085                ))),
1086            ));
1087            language_registry.add(Arc::new(
1088                Language::new(
1089                    LanguageConfig {
1090                        name: "Rust".into(),
1091                        matcher: LanguageMatcher {
1092                            path_suffixes: vec!["rs".to_string()],
1093                            ..LanguageMatcher::default()
1094                        },
1095                        ..LanguageConfig::default()
1096                    },
1097                    None,
1098                )
1099                .with_context_provider(Some(Arc::new(
1100                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1101                        label: "Rust task".to_string(),
1102                        command: "cargo check".into(),
1103                        ..TaskTemplate::default()
1104                    }])),
1105                ))),
1106            ));
1107        });
1108        let (multi_workspace, cx) =
1109            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1110        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1111
1112        let _ts_file_1 = workspace
1113            .update_in(cx, |workspace, window, cx| {
1114                workspace.open_abs_path(
1115                    PathBuf::from(path!("/dir/a1.ts")),
1116                    OpenOptions {
1117                        visible: Some(OpenVisible::All),
1118                        ..Default::default()
1119                    },
1120                    window,
1121                    cx,
1122                )
1123            })
1124            .await
1125            .unwrap();
1126        let tasks_picker = open_spawn_tasks(&workspace, cx);
1127        assert_eq!(
1128            task_names(&tasks_picker, cx),
1129            vec![
1130                concat!("Another task from file ", path!("/dir/a1.ts")),
1131                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1132                "Task without variables",
1133            ],
1134            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1135        );
1136
1137        emulate_task_schedule(
1138            tasks_picker,
1139            &project,
1140            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1141            cx,
1142        );
1143
1144        let tasks_picker = open_spawn_tasks(&workspace, cx);
1145        assert_eq!(
1146            task_names(&tasks_picker, cx),
1147            vec![
1148                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1149                concat!("Another task from file ", path!("/dir/a1.ts")),
1150                "Task without variables",
1151            ],
1152            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1153            Tasks with the same labels and context are deduplicated."
1154        );
1155        tasks_picker.update(cx, |_, cx| {
1156            cx.emit(DismissEvent);
1157        });
1158        drop(tasks_picker);
1159        cx.executor().run_until_parked();
1160
1161        let _ts_file_2 = workspace
1162            .update_in(cx, |workspace, window, cx| {
1163                workspace.open_abs_path(
1164                    PathBuf::from(path!("/dir/a2.ts")),
1165                    OpenOptions {
1166                        visible: Some(OpenVisible::All),
1167                        ..Default::default()
1168                    },
1169                    window,
1170                    cx,
1171                )
1172            })
1173            .await
1174            .unwrap();
1175        let tasks_picker = open_spawn_tasks(&workspace, cx);
1176        assert_eq!(
1177            task_names(&tasks_picker, cx),
1178            vec![
1179                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1180                concat!("Another task from file ", path!("/dir/a2.ts")),
1181                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1182                "Task without variables",
1183            ],
1184            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1185        );
1186        tasks_picker.update(cx, |_, cx| {
1187            cx.emit(DismissEvent);
1188        });
1189        drop(tasks_picker);
1190        cx.executor().run_until_parked();
1191
1192        let _rs_file = workspace
1193            .update_in(cx, |workspace, window, cx| {
1194                workspace.open_abs_path(
1195                    PathBuf::from(path!("/dir/b.rs")),
1196                    OpenOptions {
1197                        visible: Some(OpenVisible::All),
1198                        ..Default::default()
1199                    },
1200                    window,
1201                    cx,
1202                )
1203            })
1204            .await
1205            .unwrap();
1206        let tasks_picker = open_spawn_tasks(&workspace, cx);
1207        assert_eq!(
1208            task_names(&tasks_picker, cx),
1209            vec!["Rust task"],
1210            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1211        );
1212
1213        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1214        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1215        let _ts_file_2 = workspace
1216            .update_in(cx, |workspace, window, cx| {
1217                workspace.open_abs_path(
1218                    PathBuf::from(path!("/dir/a2.ts")),
1219                    OpenOptions {
1220                        visible: Some(OpenVisible::All),
1221                        ..Default::default()
1222                    },
1223                    window,
1224                    cx,
1225                )
1226            })
1227            .await
1228            .unwrap();
1229        let tasks_picker = open_spawn_tasks(&workspace, cx);
1230        assert_eq!(
1231            task_names(&tasks_picker, cx),
1232            vec![
1233                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1234                concat!("Another task from file ", path!("/dir/a2.ts")),
1235                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1236                "Task without variables",
1237            ],
1238            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1239            same TS spawn history should be restored"
1240        );
1241    }
1242
1243    fn emulate_task_schedule(
1244        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1245        project: &Entity<Project>,
1246        scheduled_task_label: &str,
1247        cx: &mut VisualTestContext,
1248    ) {
1249        let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1250            tasks_picker
1251                .delegate
1252                .candidates
1253                .iter()
1254                .flatten()
1255                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1256                .cloned()
1257                .unwrap()
1258        });
1259        project.update(cx, |project, cx| {
1260            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1261                task_inventory.update(cx, |inventory, _| {
1262                    let (kind, task) = scheduled_task;
1263                    inventory.task_scheduled(kind, task);
1264                });
1265            }
1266        });
1267        tasks_picker.update(cx, |_, cx| {
1268            cx.emit(DismissEvent);
1269        });
1270        drop(tasks_picker);
1271        cx.executor().run_until_parked()
1272    }
1273
1274    fn open_spawn_tasks(
1275        workspace: &Entity<Workspace>,
1276        cx: &mut VisualTestContext,
1277    ) -> Entity<Picker<TasksModalDelegate>> {
1278        cx.dispatch_action(Spawn::modal());
1279        workspace.update(cx, |workspace, cx| {
1280            workspace
1281                .active_modal::<TasksModal>(cx)
1282                .expect("no task modal after `Spawn` action was dispatched")
1283                .read(cx)
1284                .picker
1285                .clone()
1286        })
1287    }
1288
1289    fn query(
1290        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1291        cx: &mut VisualTestContext,
1292    ) -> String {
1293        spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1294    }
1295
1296    fn task_names(
1297        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1298        cx: &mut VisualTestContext,
1299    ) -> Vec<String> {
1300        spawn_tasks.read_with(cx, |spawn_tasks, _| {
1301            spawn_tasks
1302                .delegate
1303                .matches
1304                .iter()
1305                .map(|hit| hit.string.clone())
1306                .collect::<Vec<_>>()
1307        })
1308    }
1309}