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