modal.rs

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