modal.rs

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