modal.rs

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