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