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