modal.rs

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