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