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 {
 559            id: index,
 560            char_bag: candidate.resolved_label.chars().collect(),
 561            string: candidate.display_label().into(),
 562        })
 563        .collect()
 564}
 565
 566#[cfg(test)]
 567mod tests {
 568    use std::{path::PathBuf, sync::Arc};
 569
 570    use editor::Editor;
 571    use gpui::{TestAppContext, VisualTestContext};
 572    use language::{Language, LanguageConfig, LanguageMatcher, Point};
 573    use project::{ContextProviderWithTasks, FakeFs, Project};
 574    use serde_json::json;
 575    use task::TaskTemplates;
 576    use workspace::CloseInactiveTabsAndPanes;
 577
 578    use crate::{modal::Spawn, tests::init_test};
 579
 580    use super::*;
 581
 582    #[gpui::test]
 583    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
 584        init_test(cx);
 585        let fs = FakeFs::new(cx.executor());
 586        fs.insert_tree(
 587            "/dir",
 588            json!({
 589                ".zed": {
 590                    "tasks.json": r#"[
 591                        {
 592                            "label": "example task",
 593                            "command": "echo",
 594                            "args": ["4"]
 595                        },
 596                        {
 597                            "label": "another one",
 598                            "command": "echo",
 599                            "args": ["55"]
 600                        },
 601                    ]"#,
 602                },
 603                "a.ts": "a"
 604            }),
 605        )
 606        .await;
 607
 608        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 609        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 610
 611        let tasks_picker = open_spawn_tasks(&workspace, cx);
 612        assert_eq!(
 613            query(&tasks_picker, cx),
 614            "",
 615            "Initial query should be empty"
 616        );
 617        assert_eq!(
 618            task_names(&tasks_picker, cx),
 619            Vec::<String>::new(),
 620            "With no global tasks and no open item, no tasks should be listed"
 621        );
 622        drop(tasks_picker);
 623
 624        let _ = workspace
 625            .update(cx, |workspace, cx| {
 626                workspace.open_abs_path(PathBuf::from("/dir/a.ts"), true, cx)
 627            })
 628            .await
 629            .unwrap();
 630        let tasks_picker = open_spawn_tasks(&workspace, cx);
 631        assert_eq!(
 632            task_names(&tasks_picker, cx),
 633            vec!["another one", "example task"],
 634            "Initial tasks should be listed in alphabetical order"
 635        );
 636
 637        let query_str = "tas";
 638        cx.simulate_input(query_str);
 639        assert_eq!(query(&tasks_picker, cx), query_str);
 640        assert_eq!(
 641            task_names(&tasks_picker, cx),
 642            vec!["example task"],
 643            "Only one task should match the query {query_str}"
 644        );
 645
 646        cx.dispatch_action(picker::ConfirmCompletion);
 647        assert_eq!(
 648            query(&tasks_picker, cx),
 649            "echo 4",
 650            "Query should be set to the selected task's command"
 651        );
 652        assert_eq!(
 653            task_names(&tasks_picker, cx),
 654            Vec::<String>::new(),
 655            "No task should be listed"
 656        );
 657        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 658
 659        let tasks_picker = open_spawn_tasks(&workspace, cx);
 660        assert_eq!(
 661            query(&tasks_picker, cx),
 662            "",
 663            "Query should be reset after confirming"
 664        );
 665        assert_eq!(
 666            task_names(&tasks_picker, cx),
 667            vec!["echo 4", "another one", "example task"],
 668            "New oneshot task should be listed first"
 669        );
 670
 671        let query_str = "echo 4";
 672        cx.simulate_input(query_str);
 673        assert_eq!(query(&tasks_picker, cx), query_str);
 674        assert_eq!(
 675            task_names(&tasks_picker, cx),
 676            vec!["echo 4"],
 677            "New oneshot should match custom command query"
 678        );
 679
 680        cx.dispatch_action(picker::ConfirmInput { secondary: false });
 681        let tasks_picker = open_spawn_tasks(&workspace, cx);
 682        assert_eq!(
 683            query(&tasks_picker, cx),
 684            "",
 685            "Query should be reset after confirming"
 686        );
 687        assert_eq!(
 688            task_names(&tasks_picker, cx),
 689            vec![query_str, "another one", "example task"],
 690            "Last recently used one show task should be listed first"
 691        );
 692
 693        cx.dispatch_action(picker::ConfirmCompletion);
 694        assert_eq!(
 695            query(&tasks_picker, cx),
 696            query_str,
 697            "Query should be set to the custom task's name"
 698        );
 699        assert_eq!(
 700            task_names(&tasks_picker, cx),
 701            vec![query_str],
 702            "Only custom task should be listed"
 703        );
 704
 705        let query_str = "0";
 706        cx.simulate_input(query_str);
 707        assert_eq!(query(&tasks_picker, cx), "echo 40");
 708        assert_eq!(
 709            task_names(&tasks_picker, cx),
 710            Vec::<String>::new(),
 711            "New oneshot should not match any command query"
 712        );
 713
 714        cx.dispatch_action(picker::ConfirmInput { secondary: true });
 715        let tasks_picker = open_spawn_tasks(&workspace, cx);
 716        assert_eq!(
 717            query(&tasks_picker, cx),
 718            "",
 719            "Query should be reset after confirming"
 720        );
 721        assert_eq!(
 722            task_names(&tasks_picker, cx),
 723            vec!["echo 4", "another one", "example task"],
 724            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
 725        );
 726
 727        cx.dispatch_action(Spawn::ByName {
 728            task_name: "example task".to_string(),
 729            reveal_target: None,
 730        });
 731        let tasks_picker = workspace.update(cx, |workspace, cx| {
 732            workspace
 733                .active_modal::<TasksModal>(cx)
 734                .unwrap()
 735                .read(cx)
 736                .picker
 737                .clone()
 738        });
 739        assert_eq!(
 740            task_names(&tasks_picker, cx),
 741            vec!["echo 4", "another one", "example task"],
 742        );
 743    }
 744
 745    #[gpui::test]
 746    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 747        init_test(cx);
 748        let fs = FakeFs::new(cx.executor());
 749        fs.insert_tree(
 750            "/dir",
 751            json!({
 752                ".zed": {
 753                    "tasks.json": r#"[
 754                        {
 755                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 756                            "command": "echo",
 757                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 758                        },
 759                        {
 760                            "label": "opened now: $ZED_WORKTREE_ROOT",
 761                            "command": "echo",
 762                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 763                        }
 764                    ]"#,
 765                },
 766                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 767                "file_with.odd_extension": "b",
 768            }),
 769        )
 770        .await;
 771
 772        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 773        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 774
 775        let tasks_picker = open_spawn_tasks(&workspace, cx);
 776        assert_eq!(
 777            task_names(&tasks_picker, cx),
 778            Vec::<String>::new(),
 779            "Should list no file or worktree context-dependent when no file is open"
 780        );
 781        tasks_picker.update(cx, |_, cx| {
 782            cx.emit(DismissEvent);
 783        });
 784        drop(tasks_picker);
 785        cx.executor().run_until_parked();
 786
 787        let _ = workspace
 788            .update(cx, |workspace, cx| {
 789                workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
 790            })
 791            .await
 792            .unwrap();
 793        cx.executor().run_until_parked();
 794        let tasks_picker = open_spawn_tasks(&workspace, cx);
 795        assert_eq!(
 796            task_names(&tasks_picker, cx),
 797            vec![
 798                "hello from …th.odd_extension:1:1".to_string(),
 799                "opened now: /dir".to_string()
 800            ],
 801            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 802        );
 803        tasks_picker.update(cx, |_, cx| {
 804            cx.emit(DismissEvent);
 805        });
 806        drop(tasks_picker);
 807        cx.executor().run_until_parked();
 808
 809        let second_item = workspace
 810            .update(cx, |workspace, cx| {
 811                workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
 812            })
 813            .await
 814            .unwrap();
 815
 816        let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
 817        editor.update(cx, |editor, cx| {
 818            editor.change_selections(None, cx, |s| {
 819                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 820            })
 821        });
 822        cx.executor().run_until_parked();
 823        let tasks_picker = open_spawn_tasks(&workspace, cx);
 824        assert_eq!(
 825            task_names(&tasks_picker, cx),
 826            vec![
 827                "hello from …ithout_extension:2:3".to_string(),
 828                "opened now: /dir".to_string()
 829            ],
 830            "Opened buffer should fill the context, labels should be trimmed if long enough"
 831        );
 832        tasks_picker.update(cx, |_, cx| {
 833            cx.emit(DismissEvent);
 834        });
 835        drop(tasks_picker);
 836        cx.executor().run_until_parked();
 837    }
 838
 839    #[gpui::test]
 840    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 841        init_test(cx);
 842        let fs = FakeFs::new(cx.executor());
 843        fs.insert_tree(
 844            "/dir",
 845            json!({
 846                "a1.ts": "// a1",
 847                "a2.ts": "// a2",
 848                "b.rs": "// b",
 849            }),
 850        )
 851        .await;
 852
 853        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 854        project.read_with(cx, |project, _| {
 855            let language_registry = project.languages();
 856            language_registry.add(Arc::new(
 857                Language::new(
 858                    LanguageConfig {
 859                        name: "TypeScript".into(),
 860                        matcher: LanguageMatcher {
 861                            path_suffixes: vec!["ts".to_string()],
 862                            ..LanguageMatcher::default()
 863                        },
 864                        ..LanguageConfig::default()
 865                    },
 866                    None,
 867                )
 868                .with_context_provider(Some(Arc::new(
 869                    ContextProviderWithTasks::new(TaskTemplates(vec![
 870                        TaskTemplate {
 871                            label: "Task without variables".to_string(),
 872                            command: "npm run clean".to_string(),
 873                            ..TaskTemplate::default()
 874                        },
 875                        TaskTemplate {
 876                            label: "TypeScript task from file $ZED_FILE".to_string(),
 877                            command: "npm run build".to_string(),
 878                            ..TaskTemplate::default()
 879                        },
 880                        TaskTemplate {
 881                            label: "Another task from file $ZED_FILE".to_string(),
 882                            command: "npm run lint".to_string(),
 883                            ..TaskTemplate::default()
 884                        },
 885                    ])),
 886                ))),
 887            ));
 888            language_registry.add(Arc::new(
 889                Language::new(
 890                    LanguageConfig {
 891                        name: "Rust".into(),
 892                        matcher: LanguageMatcher {
 893                            path_suffixes: vec!["rs".to_string()],
 894                            ..LanguageMatcher::default()
 895                        },
 896                        ..LanguageConfig::default()
 897                    },
 898                    None,
 899                )
 900                .with_context_provider(Some(Arc::new(
 901                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 902                        label: "Rust task".to_string(),
 903                        command: "cargo check".into(),
 904                        ..TaskTemplate::default()
 905                    }])),
 906                ))),
 907            ));
 908        });
 909        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 910
 911        let _ts_file_1 = workspace
 912            .update(cx, |workspace, cx| {
 913                workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
 914            })
 915            .await
 916            .unwrap();
 917        let tasks_picker = open_spawn_tasks(&workspace, cx);
 918        assert_eq!(
 919            task_names(&tasks_picker, cx),
 920            vec![
 921                "Another task from file /dir/a1.ts",
 922                "TypeScript task from file /dir/a1.ts",
 923                "Task without variables",
 924            ],
 925            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 926        );
 927        emulate_task_schedule(
 928            tasks_picker,
 929            &project,
 930            "TypeScript task from file /dir/a1.ts",
 931            cx,
 932        );
 933
 934        let tasks_picker = open_spawn_tasks(&workspace, cx);
 935        assert_eq!(
 936            task_names(&tasks_picker, cx),
 937            vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
 938            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
 939            Tasks with the same labels and context are deduplicated."
 940        );
 941        tasks_picker.update(cx, |_, cx| {
 942            cx.emit(DismissEvent);
 943        });
 944        drop(tasks_picker);
 945        cx.executor().run_until_parked();
 946
 947        let _ts_file_2 = workspace
 948            .update(cx, |workspace, cx| {
 949                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 950            })
 951            .await
 952            .unwrap();
 953        let tasks_picker = open_spawn_tasks(&workspace, cx);
 954        assert_eq!(
 955            task_names(&tasks_picker, cx),
 956            vec![
 957                "TypeScript task from file /dir/a1.ts",
 958                "Another task from file /dir/a2.ts",
 959                "TypeScript task from file /dir/a2.ts",
 960                "Task without variables"
 961            ],
 962            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
 963        );
 964        tasks_picker.update(cx, |_, cx| {
 965            cx.emit(DismissEvent);
 966        });
 967        drop(tasks_picker);
 968        cx.executor().run_until_parked();
 969
 970        let _rs_file = workspace
 971            .update(cx, |workspace, cx| {
 972                workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
 973            })
 974            .await
 975            .unwrap();
 976        let tasks_picker = open_spawn_tasks(&workspace, cx);
 977        assert_eq!(
 978            task_names(&tasks_picker, cx),
 979            vec!["Rust task"],
 980            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
 981        );
 982
 983        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
 984        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
 985        let _ts_file_2 = workspace
 986            .update(cx, |workspace, cx| {
 987                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 988            })
 989            .await
 990            .unwrap();
 991        let tasks_picker = open_spawn_tasks(&workspace, cx);
 992        assert_eq!(
 993            task_names(&tasks_picker, cx),
 994            vec![
 995                "TypeScript task from file /dir/a1.ts",
 996                "Another task from file /dir/a2.ts",
 997                "TypeScript task from file /dir/a2.ts",
 998                "Task without variables"
 999            ],
1000            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1001            same TS spawn history should be restored"
1002        );
1003    }
1004
1005    fn emulate_task_schedule(
1006        tasks_picker: View<Picker<TasksModalDelegate>>,
1007        project: &Model<Project>,
1008        scheduled_task_label: &str,
1009        cx: &mut VisualTestContext,
1010    ) {
1011        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1012            tasks_picker
1013                .delegate
1014                .candidates
1015                .iter()
1016                .flatten()
1017                .find(|(_, task)| task.resolved_label == scheduled_task_label)
1018                .cloned()
1019                .unwrap()
1020        });
1021        project.update(cx, |project, cx| {
1022            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1023                task_inventory.update(cx, |inventory, _| {
1024                    let (kind, task) = scheduled_task;
1025                    inventory.task_scheduled(kind, task);
1026                });
1027            }
1028        });
1029        tasks_picker.update(cx, |_, cx| {
1030            cx.emit(DismissEvent);
1031        });
1032        drop(tasks_picker);
1033        cx.executor().run_until_parked()
1034    }
1035
1036    fn open_spawn_tasks(
1037        workspace: &View<Workspace>,
1038        cx: &mut VisualTestContext,
1039    ) -> View<Picker<TasksModalDelegate>> {
1040        cx.dispatch_action(Spawn::modal());
1041        workspace.update(cx, |workspace, cx| {
1042            workspace
1043                .active_modal::<TasksModal>(cx)
1044                .expect("no task modal after `Spawn` action was dispatched")
1045                .read(cx)
1046                .picker
1047                .clone()
1048        })
1049    }
1050
1051    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1052        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1053    }
1054
1055    fn task_names(
1056        spawn_tasks: &View<Picker<TasksModalDelegate>>,
1057        cx: &mut VisualTestContext,
1058    ) -> Vec<String> {
1059        spawn_tasks.update(cx, |spawn_tasks, _| {
1060            spawn_tasks
1061                .delegate
1062                .matches
1063                .iter()
1064                .map(|hit| hit.string.clone())
1065                .collect::<Vec<_>>()
1066        })
1067    }
1068}