modal.rs

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