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