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