modal.rs

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