modal.rs

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