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_md()
 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;
 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(PathBuf::from(path!("/dir/a.ts")), true, window, cx)
 657            })
 658            .await
 659            .unwrap();
 660        let tasks_picker = open_spawn_tasks(&workspace, cx);
 661        assert_eq!(
 662            task_names(&tasks_picker, cx),
 663            vec!["another one", "example task"],
 664            "Initial tasks should be listed in alphabetical order"
 665        );
 666
 667        let query_str = "tas";
 668        cx.simulate_input(query_str);
 669        assert_eq!(query(&tasks_picker, cx), query_str);
 670        assert_eq!(
 671            task_names(&tasks_picker, cx),
 672            vec!["example task"],
 673            "Only one task should match the query {query_str}"
 674        );
 675
 676        cx.dispatch_action(picker::ConfirmCompletion);
 677        assert_eq!(
 678            query(&tasks_picker, cx),
 679            "echo 4",
 680            "Query should be set to the selected task's command"
 681        );
 682        assert_eq!(
 683            task_names(&tasks_picker, cx),
 684            Vec::<String>::new(),
 685            "No task should be listed"
 686        );
 687        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 688
 689        let tasks_picker = open_spawn_tasks(&workspace, cx);
 690        assert_eq!(
 691            query(&tasks_picker, cx),
 692            "",
 693            "Query should be reset after confirming"
 694        );
 695        assert_eq!(
 696            task_names(&tasks_picker, cx),
 697            vec!["echo 4", "another one", "example task"],
 698            "New oneshot task should be listed first"
 699        );
 700
 701        let query_str = "echo 4";
 702        cx.simulate_input(query_str);
 703        assert_eq!(query(&tasks_picker, cx), query_str);
 704        assert_eq!(
 705            task_names(&tasks_picker, cx),
 706            vec!["echo 4"],
 707            "New oneshot should match custom command query"
 708        );
 709
 710        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 711        let tasks_picker = open_spawn_tasks(&workspace, cx);
 712        assert_eq!(
 713            query(&tasks_picker, cx),
 714            "",
 715            "Query should be reset after confirming"
 716        );
 717        assert_eq!(
 718            task_names(&tasks_picker, cx),
 719            vec![query_str, "another one", "example task"],
 720            "Last recently used one show task should be listed first"
 721        );
 722
 723        cx.dispatch_action(picker::ConfirmCompletion);
 724        assert_eq!(
 725            query(&tasks_picker, cx),
 726            query_str,
 727            "Query should be set to the custom task's name"
 728        );
 729        assert_eq!(
 730            task_names(&tasks_picker, cx),
 731            vec![query_str],
 732            "Only custom task should be listed"
 733        );
 734
 735        let query_str = "0";
 736        cx.simulate_input(query_str);
 737        assert_eq!(query(&tasks_picker, cx), "echo 40");
 738        assert_eq!(
 739            task_names(&tasks_picker, cx),
 740            Vec::<String>::new(),
 741            "New oneshot should not match any command query"
 742        );
 743
 744        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 745        let tasks_picker = open_spawn_tasks(&workspace, cx);
 746        assert_eq!(
 747            query(&tasks_picker, cx),
 748            "",
 749            "Query should be reset after confirming"
 750        );
 751        assert_eq!(
 752            task_names(&tasks_picker, cx),
 753            vec!["echo 4", "another one", "example task"],
 754            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 755        );
 756
 757        cx.dispatch_action(Spawn::ByName {
 758            task_name: "example task".to_string(),
 759            reveal_target: None,
 760        });
 761        let tasks_picker = workspace.update(cx, |workspace, cx| {
 762            workspace
 763                .active_modal::<TasksModal>(cx)
 764                .unwrap()
 765                .read(cx)
 766                .picker
 767                .clone()
 768        });
 769        assert_eq!(
 770            task_names(&tasks_picker, cx),
 771            vec!["echo 4", "another one", "example task"],
 772        );
 773    }
 774
 775    #[gpui::test]
 776    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 777        init_test(cx);
 778        let fs = FakeFs::new(cx.executor());
 779        fs.insert_tree(
 780            path!("/dir"),
 781            json!({
 782                ".zed": {
 783                    "tasks.json": r#"[
 784                        {
 785                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 786                            "command": "echo",
 787                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 788                        },
 789                        {
 790                            "label": "opened now: $ZED_WORKTREE_ROOT",
 791                            "command": "echo",
 792                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 793                        }
 794                    ]"#,
 795                },
 796                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 797                "file_with.odd_extension": "b",
 798            }),
 799        )
 800        .await;
 801
 802        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 803        let (workspace, cx) =
 804            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 805
 806        let tasks_picker = open_spawn_tasks(&workspace, cx);
 807        assert_eq!(
 808            task_names(&tasks_picker, cx),
 809            vec![concat!("opened now: ", path!("/dir")).to_string()],
 810            "When no file is open for a single worktree, should autodetect all worktree-related tasks"
 811        );
 812        tasks_picker.update(cx, |_, cx| {
 813            cx.emit(DismissEvent);
 814        });
 815        drop(tasks_picker);
 816        cx.executor().run_until_parked();
 817
 818        let _ = workspace
 819            .update_in(cx, |workspace, window, cx| {
 820                workspace.open_abs_path(
 821                    PathBuf::from(path!("/dir/file_with.odd_extension")),
 822                    true,
 823                    window,
 824                    cx,
 825                )
 826            })
 827            .await
 828            .unwrap();
 829        cx.executor().run_until_parked();
 830        let tasks_picker = open_spawn_tasks(&workspace, cx);
 831        assert_eq!(
 832            task_names(&tasks_picker, cx),
 833            vec![
 834                concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
 835                concat!("opened now: ", path!("/dir")).to_string(),
 836            ],
 837            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 838        );
 839        tasks_picker.update(cx, |_, cx| {
 840            cx.emit(DismissEvent);
 841        });
 842        drop(tasks_picker);
 843        cx.executor().run_until_parked();
 844
 845        let second_item = workspace
 846            .update_in(cx, |workspace, window, cx| {
 847                workspace.open_abs_path(
 848                    PathBuf::from(path!("/dir/file_without_extension")),
 849                    true,
 850                    window,
 851                    cx,
 852                )
 853            })
 854            .await
 855            .unwrap();
 856
 857        let editor = cx
 858            .update(|_window, cx| second_item.act_as::<Editor>(cx))
 859            .unwrap();
 860        editor.update_in(cx, |editor, window, cx| {
 861            editor.change_selections(None, window, cx, |s| {
 862                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 863            })
 864        });
 865        cx.executor().run_until_parked();
 866        let tasks_picker = open_spawn_tasks(&workspace, cx);
 867        assert_eq!(
 868            task_names(&tasks_picker, cx),
 869            vec![
 870                concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
 871                concat!("opened now: ", path!("/dir")).to_string(),
 872            ],
 873            "Opened buffer should fill the context, labels should be trimmed if long enough"
 874        );
 875        tasks_picker.update(cx, |_, cx| {
 876            cx.emit(DismissEvent);
 877        });
 878        drop(tasks_picker);
 879        cx.executor().run_until_parked();
 880    }
 881
 882    #[gpui::test]
 883    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 884        init_test(cx);
 885        let fs = FakeFs::new(cx.executor());
 886        fs.insert_tree(
 887            path!("/dir"),
 888            json!({
 889                "a1.ts": "// a1",
 890                "a2.ts": "// a2",
 891                "b.rs": "// b",
 892            }),
 893        )
 894        .await;
 895
 896        let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
 897        project.read_with(cx, |project, _| {
 898            let language_registry = project.languages();
 899            language_registry.add(Arc::new(
 900                Language::new(
 901                    LanguageConfig {
 902                        name: "TypeScript".into(),
 903                        matcher: LanguageMatcher {
 904                            path_suffixes: vec!["ts".to_string()],
 905                            ..LanguageMatcher::default()
 906                        },
 907                        ..LanguageConfig::default()
 908                    },
 909                    None,
 910                )
 911                .with_context_provider(Some(Arc::new(
 912                    ContextProviderWithTasks::new(TaskTemplates(vec![
 913                        TaskTemplate {
 914                            label: "Task without variables".to_string(),
 915                            command: "npm run clean".to_string(),
 916                            ..TaskTemplate::default()
 917                        },
 918                        TaskTemplate {
 919                            label: "TypeScript task from file $ZED_FILE".to_string(),
 920                            command: "npm run build".to_string(),
 921                            ..TaskTemplate::default()
 922                        },
 923                        TaskTemplate {
 924                            label: "Another task from file $ZED_FILE".to_string(),
 925                            command: "npm run lint".to_string(),
 926                            ..TaskTemplate::default()
 927                        },
 928                    ])),
 929                ))),
 930            ));
 931            language_registry.add(Arc::new(
 932                Language::new(
 933                    LanguageConfig {
 934                        name: "Rust".into(),
 935                        matcher: LanguageMatcher {
 936                            path_suffixes: vec!["rs".to_string()],
 937                            ..LanguageMatcher::default()
 938                        },
 939                        ..LanguageConfig::default()
 940                    },
 941                    None,
 942                )
 943                .with_context_provider(Some(Arc::new(
 944                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 945                        label: "Rust task".to_string(),
 946                        command: "cargo check".into(),
 947                        ..TaskTemplate::default()
 948                    }])),
 949                ))),
 950            ));
 951        });
 952        let (workspace, cx) =
 953            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 954
 955        let _ts_file_1 = workspace
 956            .update_in(cx, |workspace, window, cx| {
 957                workspace.open_abs_path(PathBuf::from(path!("/dir/a1.ts")), true, window, cx)
 958            })
 959            .await
 960            .unwrap();
 961        let tasks_picker = open_spawn_tasks(&workspace, cx);
 962        assert_eq!(
 963            task_names(&tasks_picker, cx),
 964            vec![
 965                concat!("Another task from file ", path!("/dir/a1.ts")),
 966                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
 967                "Task without variables",
 968            ],
 969            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 970        );
 971
 972        emulate_task_schedule(
 973            tasks_picker,
 974            &project,
 975            concat!("TypeScript task from file ", path!("/dir/a1.ts")),
 976            cx,
 977        );
 978
 979        let tasks_picker = open_spawn_tasks(&workspace, cx);
 980        assert_eq!(
 981            task_names(&tasks_picker, cx),
 982            vec![
 983                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
 984                concat!("Another task from file ", path!("/dir/a1.ts")),
 985                "Task without variables",
 986            ],
 987            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
 988            Tasks with the same labels and context are deduplicated."
 989        );
 990        tasks_picker.update(cx, |_, cx| {
 991            cx.emit(DismissEvent);
 992        });
 993        drop(tasks_picker);
 994        cx.executor().run_until_parked();
 995
 996        let _ts_file_2 = workspace
 997            .update_in(cx, |workspace, window, cx| {
 998                workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
 999            })
1000            .await
1001            .unwrap();
1002        let tasks_picker = open_spawn_tasks(&workspace, cx);
1003        assert_eq!(
1004            task_names(&tasks_picker, cx),
1005            vec![
1006                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1007                concat!("Another task from file ", path!("/dir/a2.ts")),
1008                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1009                "Task without variables",
1010            ],
1011            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1012        );
1013        tasks_picker.update(cx, |_, cx| {
1014            cx.emit(DismissEvent);
1015        });
1016        drop(tasks_picker);
1017        cx.executor().run_until_parked();
1018
1019        let _rs_file = workspace
1020            .update_in(cx, |workspace, window, cx| {
1021                workspace.open_abs_path(PathBuf::from(path!("/dir/b.rs")), true, window, cx)
1022            })
1023            .await
1024            .unwrap();
1025        let tasks_picker = open_spawn_tasks(&workspace, cx);
1026        assert_eq!(
1027            task_names(&tasks_picker, cx),
1028            vec!["Rust task"],
1029            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1030        );
1031
1032        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1033        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1034        let _ts_file_2 = workspace
1035            .update_in(cx, |workspace, window, cx| {
1036                workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
1037            })
1038            .await
1039            .unwrap();
1040        let tasks_picker = open_spawn_tasks(&workspace, cx);
1041        assert_eq!(
1042            task_names(&tasks_picker, cx),
1043            vec![
1044                concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1045                concat!("Another task from file ", path!("/dir/a2.ts")),
1046                concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1047                "Task without variables",
1048            ],
1049            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1050            same TS spawn history should be restored"
1051        );
1052    }
1053
1054    fn emulate_task_schedule(
1055        tasks_picker: Entity<Picker<TasksModalDelegate>>,
1056        project: &Entity<Project>,
1057        scheduled_task_label: &str,
1058        cx: &mut VisualTestContext,
1059    ) {
1060        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1061            tasks_picker
1062                .delegate
1063                .candidates
1064                .iter()
1065                .flatten()
1066                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1067                .cloned()
1068                .unwrap()
1069        });
1070        project.update(cx, |project, cx| {
1071            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1072                task_inventory.update(cx, |inventory, _| {
1073                    let (kind, task) = scheduled_task;
1074                    inventory.task_scheduled(kind, task);
1075                });
1076            }
1077        });
1078        tasks_picker.update(cx, |_, cx| {
1079            cx.emit(DismissEvent);
1080        });
1081        drop(tasks_picker);
1082        cx.executor().run_until_parked()
1083    }
1084
1085    fn open_spawn_tasks(
1086        workspace: &Entity<Workspace>,
1087        cx: &mut VisualTestContext,
1088    ) -> Entity<Picker<TasksModalDelegate>> {
1089        cx.dispatch_action(Spawn::modal());
1090        workspace.update(cx, |workspace, cx| {
1091            workspace
1092                .active_modal::<TasksModal>(cx)
1093                .expect("no task modal after `Spawn` action was dispatched")
1094                .read(cx)
1095                .picker
1096                .clone()
1097        })
1098    }
1099
1100    fn query(
1101        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1102        cx: &mut VisualTestContext,
1103    ) -> String {
1104        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1105    }
1106
1107    fn task_names(
1108        spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1109        cx: &mut VisualTestContext,
1110    ) -> Vec<String> {
1111        spawn_tasks.update(cx, |spawn_tasks, _| {
1112            spawn_tasks
1113                .delegate
1114                .matches
1115                .iter()
1116                .map(|hit| hit.string.clone())
1117                .collect::<Vec<_>>()
1118        })
1119    }
1120}