modal.rs

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