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