modal.rs

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