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