Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:39:37.810Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

modal.rs

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