Skip to repository content1417 lines · 52.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:39:40.807Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
modal.rs
1use std::sync::Arc;
2
3use crate::TaskContexts;
4use editor::Editor;
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter,
8 Focusable, InteractiveElement, ParentElement, Render, 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