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