modal.rs

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