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