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