modal.rs

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