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