modal.rs

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