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, 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    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, Default::default(), &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                .toggle_state(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().into(),
 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            target: None,
 688        });
 689        let tasks_picker = workspace.update(cx, |workspace, cx| {
 690            workspace
 691                .active_modal::<TasksModal>(cx)
 692                .unwrap()
 693                .read(cx)
 694                .picker
 695                .clone()
 696        });
 697        assert_eq!(
 698            task_names(&tasks_picker, cx),
 699            vec!["echo 4", "another one", "example task"],
 700        );
 701    }
 702
 703    #[gpui::test]
 704    async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
 705        init_test(cx);
 706        let fs = FakeFs::new(cx.executor());
 707        fs.insert_tree(
 708            "/dir",
 709            json!({
 710                ".zed": {
 711                    "tasks.json": r#"[
 712                        {
 713                            "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
 714                            "command": "echo",
 715                            "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
 716                        },
 717                        {
 718                            "label": "opened now: $ZED_WORKTREE_ROOT",
 719                            "command": "echo",
 720                            "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
 721                        }
 722                    ]"#,
 723                },
 724                "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
 725                "file_with.odd_extension": "b",
 726            }),
 727        )
 728        .await;
 729
 730        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 731        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 732
 733        let tasks_picker = open_spawn_tasks(&workspace, cx);
 734        assert_eq!(
 735            task_names(&tasks_picker, cx),
 736            Vec::<String>::new(),
 737            "Should list no file or worktree context-dependent when no file is open"
 738        );
 739        tasks_picker.update(cx, |_, cx| {
 740            cx.emit(DismissEvent);
 741        });
 742        drop(tasks_picker);
 743        cx.executor().run_until_parked();
 744
 745        let _ = workspace
 746            .update(cx, |workspace, cx| {
 747                workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
 748            })
 749            .await
 750            .unwrap();
 751        cx.executor().run_until_parked();
 752        let tasks_picker = open_spawn_tasks(&workspace, cx);
 753        assert_eq!(
 754            task_names(&tasks_picker, cx),
 755            vec![
 756                "hello from …th.odd_extension:1:1".to_string(),
 757                "opened now: /dir".to_string()
 758            ],
 759            "Second opened buffer should fill the context, labels should be trimmed if long enough"
 760        );
 761        tasks_picker.update(cx, |_, cx| {
 762            cx.emit(DismissEvent);
 763        });
 764        drop(tasks_picker);
 765        cx.executor().run_until_parked();
 766
 767        let second_item = workspace
 768            .update(cx, |workspace, cx| {
 769                workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
 770            })
 771            .await
 772            .unwrap();
 773
 774        let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
 775        editor.update(cx, |editor, cx| {
 776            editor.change_selections(None, cx, |s| {
 777                s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
 778            })
 779        });
 780        cx.executor().run_until_parked();
 781        let tasks_picker = open_spawn_tasks(&workspace, cx);
 782        assert_eq!(
 783            task_names(&tasks_picker, cx),
 784            vec![
 785                "hello from …ithout_extension:2:3".to_string(),
 786                "opened now: /dir".to_string()
 787            ],
 788            "Opened buffer should fill the context, labels should be trimmed if long enough"
 789        );
 790        tasks_picker.update(cx, |_, cx| {
 791            cx.emit(DismissEvent);
 792        });
 793        drop(tasks_picker);
 794        cx.executor().run_until_parked();
 795    }
 796
 797    #[gpui::test]
 798    async fn test_language_task_filtering(cx: &mut TestAppContext) {
 799        init_test(cx);
 800        let fs = FakeFs::new(cx.executor());
 801        fs.insert_tree(
 802            "/dir",
 803            json!({
 804                "a1.ts": "// a1",
 805                "a2.ts": "// a2",
 806                "b.rs": "// b",
 807            }),
 808        )
 809        .await;
 810
 811        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
 812        project.read_with(cx, |project, _| {
 813            let language_registry = project.languages();
 814            language_registry.add(Arc::new(
 815                Language::new(
 816                    LanguageConfig {
 817                        name: "TypeScript".into(),
 818                        matcher: LanguageMatcher {
 819                            path_suffixes: vec!["ts".to_string()],
 820                            ..LanguageMatcher::default()
 821                        },
 822                        ..LanguageConfig::default()
 823                    },
 824                    None,
 825                )
 826                .with_context_provider(Some(Arc::new(
 827                    ContextProviderWithTasks::new(TaskTemplates(vec![
 828                        TaskTemplate {
 829                            label: "Task without variables".to_string(),
 830                            command: "npm run clean".to_string(),
 831                            ..TaskTemplate::default()
 832                        },
 833                        TaskTemplate {
 834                            label: "TypeScript task from file $ZED_FILE".to_string(),
 835                            command: "npm run build".to_string(),
 836                            ..TaskTemplate::default()
 837                        },
 838                        TaskTemplate {
 839                            label: "Another task from file $ZED_FILE".to_string(),
 840                            command: "npm run lint".to_string(),
 841                            ..TaskTemplate::default()
 842                        },
 843                    ])),
 844                ))),
 845            ));
 846            language_registry.add(Arc::new(
 847                Language::new(
 848                    LanguageConfig {
 849                        name: "Rust".into(),
 850                        matcher: LanguageMatcher {
 851                            path_suffixes: vec!["rs".to_string()],
 852                            ..LanguageMatcher::default()
 853                        },
 854                        ..LanguageConfig::default()
 855                    },
 856                    None,
 857                )
 858                .with_context_provider(Some(Arc::new(
 859                    ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
 860                        label: "Rust task".to_string(),
 861                        command: "cargo check".into(),
 862                        ..TaskTemplate::default()
 863                    }])),
 864                ))),
 865            ));
 866        });
 867        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
 868
 869        let _ts_file_1 = workspace
 870            .update(cx, |workspace, cx| {
 871                workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
 872            })
 873            .await
 874            .unwrap();
 875        let tasks_picker = open_spawn_tasks(&workspace, cx);
 876        assert_eq!(
 877            task_names(&tasks_picker, cx),
 878            vec![
 879                "Another task from file /dir/a1.ts",
 880                "TypeScript task from file /dir/a1.ts",
 881                "Task without variables",
 882            ],
 883            "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
 884        );
 885        emulate_task_schedule(
 886            tasks_picker,
 887            &project,
 888            "TypeScript task from file /dir/a1.ts",
 889            cx,
 890        );
 891
 892        let tasks_picker = open_spawn_tasks(&workspace, cx);
 893        assert_eq!(
 894            task_names(&tasks_picker, cx),
 895            vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
 896            "After spawning the task and getting it into the history, it should be up in the sort as recently used.
 897            Tasks with the same labels and context are deduplicated."
 898        );
 899        tasks_picker.update(cx, |_, cx| {
 900            cx.emit(DismissEvent);
 901        });
 902        drop(tasks_picker);
 903        cx.executor().run_until_parked();
 904
 905        let _ts_file_2 = workspace
 906            .update(cx, |workspace, cx| {
 907                workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
 908            })
 909            .await
 910            .unwrap();
 911        let tasks_picker = open_spawn_tasks(&workspace, cx);
 912        assert_eq!(
 913            task_names(&tasks_picker, cx),
 914            vec![
 915                "TypeScript task from file /dir/a1.ts",
 916                "Another task from file /dir/a2.ts",
 917                "TypeScript task from file /dir/a2.ts",
 918                "Task without variables"
 919            ],
 920            "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
 921        );
 922        tasks_picker.update(cx, |_, cx| {
 923            cx.emit(DismissEvent);
 924        });
 925        drop(tasks_picker);
 926        cx.executor().run_until_parked();
 927
 928        let _rs_file = workspace
 929            .update(cx, |workspace, cx| {
 930                workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
 931            })
 932            .await
 933            .unwrap();
 934        let tasks_picker = open_spawn_tasks(&workspace, cx);
 935        assert_eq!(
 936            task_names(&tasks_picker, cx),
 937            vec!["Rust task"],
 938            "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
 939        );
 940
 941        cx.dispatch_action(CloseInactiveTabsAndPanes::default());
 942        emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
 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            "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
 959            same TS spawn history should be restored"
 960        );
 961    }
 962
 963    fn emulate_task_schedule(
 964        tasks_picker: View<Picker<TasksModalDelegate>>,
 965        project: &Model<Project>,
 966        scheduled_task_label: &str,
 967        cx: &mut VisualTestContext,
 968    ) {
 969        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
 970            tasks_picker
 971                .delegate
 972                .candidates
 973                .iter()
 974                .flatten()
 975                .find(|(_, task)| task.resolved_label == scheduled_task_label)
 976                .cloned()
 977                .unwrap()
 978        });
 979        project.update(cx, |project, cx| {
 980            if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
 981                task_inventory.update(cx, |inventory, _| {
 982                    let (kind, task) = scheduled_task;
 983                    inventory.task_scheduled(kind, task);
 984                });
 985            }
 986        });
 987        tasks_picker.update(cx, |_, cx| {
 988            cx.emit(DismissEvent);
 989        });
 990        drop(tasks_picker);
 991        cx.executor().run_until_parked()
 992    }
 993
 994    fn open_spawn_tasks(
 995        workspace: &View<Workspace>,
 996        cx: &mut VisualTestContext,
 997    ) -> View<Picker<TasksModalDelegate>> {
 998        cx.dispatch_action(Spawn::default());
 999        workspace.update(cx, |workspace, cx| {
1000            workspace
1001                .active_modal::<TasksModal>(cx)
1002                .expect("no task modal after `Spawn` action was dispatched")
1003                .read(cx)
1004                .picker
1005                .clone()
1006        })
1007    }
1008
1009    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1010        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1011    }
1012
1013    fn task_names(
1014        spawn_tasks: &View<Picker<TasksModalDelegate>>,
1015        cx: &mut VisualTestContext,
1016    ) -> Vec<String> {
1017        spawn_tasks.update(cx, |spawn_tasks, _| {
1018            spawn_tasks
1019                .delegate
1020                .matches
1021                .iter()
1022                .map(|hit| hit.string.clone())
1023                .collect::<Vec<_>>()
1024        })
1025    }
1026}