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