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