modal.rs

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