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::{DebugScenario, ResolvedTask, RevealTarget, TaskContext, TaskModal, TaskTemplate};
14use ui::{
15 ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon,
16 IconButton, IconButtonShape, IconName, IconSize, IntoElement, KeyBinding, Label, LabelSize,
17 ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip, div, h_flex, v_flex,
18};
19
20use util::{ResultExt, truncate_and_trailoff};
21use workspace::{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 struct TasksModal {
129 picker: Entity<Picker<TasksModalDelegate>>,
130 _subscription: [Subscription; 2],
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 = [
157 cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
158 cx.emit(DismissEvent);
159 }),
160 cx.subscribe(&picker, |_, _, event: &ShowAttachModal, cx| {
161 cx.emit(ShowAttachModal {
162 debug_config: event.debug_config.clone(),
163 });
164 }),
165 ];
166 Self {
167 picker,
168 _subscription,
169 }
170 }
171}
172
173impl Render for TasksModal {
174 fn render(
175 &mut self,
176 _window: &mut Window,
177 _: &mut Context<Self>,
178 ) -> impl gpui::prelude::IntoElement {
179 v_flex()
180 .key_context("TasksModal")
181 .w(rems(34.))
182 .child(self.picker.clone())
183 }
184}
185
186pub struct ShowAttachModal {
187 pub debug_config: DebugScenario,
188}
189
190impl EventEmitter<DismissEvent> for TasksModal {}
191impl EventEmitter<ShowAttachModal> for TasksModal {}
192impl EventEmitter<ShowAttachModal> for Picker<TasksModalDelegate> {}
193
194impl Focusable for TasksModal {
195 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
196 self.picker.read(cx).focus_handle(cx)
197 }
198}
199
200impl ModalView for TasksModal {}
201
202const MAX_TAGS_LINE_LEN: usize = 30;
203
204impl PickerDelegate for TasksModalDelegate {
205 type ListItem = ListItem;
206
207 fn match_count(&self) -> usize {
208 self.matches.len()
209 }
210
211 fn selected_index(&self) -> usize {
212 self.selected_index
213 }
214
215 fn set_selected_index(
216 &mut self,
217 ix: usize,
218 _window: &mut Window,
219 _cx: &mut Context<picker::Picker<Self>>,
220 ) {
221 self.selected_index = ix;
222 }
223
224 fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc<str> {
225 self.placeholder_text.clone()
226 }
227
228 fn update_matches(
229 &mut self,
230 query: String,
231 window: &mut Window,
232 cx: &mut Context<picker::Picker<Self>>,
233 ) -> Task<()> {
234 let task_type = self.task_modal_type.clone();
235 let candidates = match &self.candidates {
236 Some(candidates) => Task::ready(string_match_candidates(candidates, task_type)),
237 None => {
238 if let Some(task_inventory) = self.task_store.read(cx).task_inventory().cloned() {
239 let (used, current) = task_inventory
240 .read(cx)
241 .used_and_current_resolved_tasks(&self.task_contexts, cx);
242 let workspace = self.workspace.clone();
243 let lsp_task_sources = self.task_contexts.lsp_task_sources.clone();
244 let task_position = self.task_contexts.latest_selection;
245
246 cx.spawn(async move |picker, cx| {
247 let Ok(lsp_tasks) = workspace.update(cx, |workspace, cx| {
248 editor::lsp_tasks(
249 workspace.project().clone(),
250 &lsp_task_sources,
251 task_position,
252 cx,
253 )
254 }) else {
255 return Vec::new();
256 };
257
258 let lsp_tasks = lsp_tasks.await;
259 picker
260 .update(cx, |picker, _| {
261 picker.delegate.last_used_candidate_index = if used.is_empty() {
262 None
263 } else {
264 Some(used.len() - 1)
265 };
266
267 let mut new_candidates = used;
268 new_candidates.extend(lsp_tasks.into_iter().flat_map(
269 |(kind, tasks_with_locations)| {
270 tasks_with_locations
271 .into_iter()
272 .sorted_by_key(|(location, task)| {
273 (location.is_none(), task.resolved_label.clone())
274 })
275 .map(move |(_, task)| (kind.clone(), task))
276 },
277 ));
278 new_candidates.extend(current);
279 let match_candidates =
280 string_match_candidates(&new_candidates, task_type);
281 let _ = picker.delegate.candidates.insert(new_candidates);
282 match_candidates
283 })
284 .ok()
285 .unwrap_or_default()
286 })
287 } else {
288 Task::ready(Vec::new())
289 }
290 }
291 };
292
293 cx.spawn_in(window, async move |picker, cx| {
294 let candidates = candidates.await;
295 let matches = fuzzy::match_strings(
296 &candidates,
297 &query,
298 true,
299 1000,
300 &Default::default(),
301 cx.background_executor().clone(),
302 )
303 .await;
304 picker
305 .update(cx, |picker, _| {
306 let delegate = &mut picker.delegate;
307 delegate.matches = matches;
308 if let Some(index) = delegate.last_used_candidate_index {
309 delegate.matches.sort_by_key(|m| m.candidate_id > index);
310 }
311
312 delegate.prompt = query;
313 delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
314 let index = delegate
315 .matches
316 .partition_point(|matching_task| matching_task.candidate_id <= index);
317 Some(index).and_then(|index| (index != 0).then(|| index - 1))
318 });
319
320 if delegate.matches.is_empty() {
321 delegate.selected_index = 0;
322 } else {
323 delegate.selected_index =
324 delegate.selected_index.min(delegate.matches.len() - 1);
325 }
326 })
327 .log_err();
328 })
329 }
330
331 fn confirm(
332 &mut self,
333 omit_history_entry: bool,
334 window: &mut Window,
335 cx: &mut Context<picker::Picker<Self>>,
336 ) {
337 let current_match_index = self.selected_index();
338 let task = self
339 .matches
340 .get(current_match_index)
341 .and_then(|current_match| {
342 let ix = current_match.candidate_id;
343 self.candidates
344 .as_ref()
345 .map(|candidates| candidates[ix].clone())
346 });
347 let Some((task_source_kind, mut task)) = task else {
348 return;
349 };
350 if let Some(TaskOverrides {
351 reveal_target: Some(reveal_target),
352 }) = &self.task_overrides
353 {
354 task.resolved.reveal_target = *reveal_target;
355 }
356
357 self.workspace
358 .update(cx, |workspace, cx| {
359 workspace.schedule_resolved_task(
360 task_source_kind,
361 task,
362 omit_history_entry,
363 window,
364 cx,
365 );
366 })
367 .ok();
368
369 cx.emit(DismissEvent);
370 }
371
372 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
373 cx.emit(DismissEvent);
374 }
375
376 fn render_match(
377 &self,
378 ix: usize,
379 selected: bool,
380 window: &mut Window,
381 cx: &mut Context<picker::Picker<Self>>,
382 ) -> Option<Self::ListItem> {
383 let candidates = self.candidates.as_ref()?;
384 let hit = &self.matches[ix];
385 let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
386 let template = resolved_task.original_task();
387 let display_label = resolved_task.display_label();
388
389 let mut tooltip_label_text = if display_label != &template.label {
390 resolved_task.resolved_label.clone()
391 } else {
392 String::new()
393 };
394
395 if resolved_task.resolved.command_label != resolved_task.resolved_label {
396 if !tooltip_label_text.trim().is_empty() {
397 tooltip_label_text.push('\n');
398 }
399 tooltip_label_text.push_str(&resolved_task.resolved.command_label);
400 }
401
402 if template.tags.len() > 0 {
403 tooltip_label_text.push('\n');
404 tooltip_label_text.push_str(
405 template
406 .tags
407 .iter()
408 .map(|tag| format!("\n#{}", tag))
409 .collect::<Vec<_>>()
410 .join("")
411 .as_str(),
412 );
413 }
414 let tooltip_label = if tooltip_label_text.trim().is_empty() {
415 None
416 } else {
417 Some(Tooltip::simple(tooltip_label_text, cx))
418 };
419
420 let highlighted_location = HighlightedMatch {
421 text: hit.string.clone(),
422 highlight_positions: hit.positions.clone(),
423 char_count: hit.string.chars().count(),
424 color: Color::Default,
425 };
426 let icon = match source_kind {
427 TaskSourceKind::Lsp(..) => Some(Icon::new(IconName::Bolt)),
428 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
429 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
430 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
431 TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
432 .get_icon_for_type(&name.to_lowercase(), cx)
433 .map(Icon::from_path),
434 }
435 .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
436 let history_run_icon = if Some(ix) <= self.divider_index {
437 Some(
438 Icon::new(IconName::HistoryRerun)
439 .color(Color::Muted)
440 .size(IconSize::Small)
441 .into_any_element(),
442 )
443 } else {
444 Some(
445 v_flex()
446 .flex_none()
447 .size(IconSize::Small.rems())
448 .into_any_element(),
449 )
450 };
451
452 Some(
453 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
454 .inset(true)
455 .start_slot::<Icon>(icon)
456 .end_slot::<AnyElement>(
457 h_flex()
458 .gap_1()
459 .child(Label::new(truncate_and_trailoff(
460 &template
461 .tags
462 .iter()
463 .map(|tag| format!("#{}", tag))
464 .collect::<Vec<_>>()
465 .join(" "),
466 MAX_TAGS_LINE_LEN,
467 )))
468 .flex_none()
469 .child(history_run_icon.unwrap())
470 .into_any_element(),
471 )
472 .spacing(ListItemSpacing::Sparse)
473 .when_some(tooltip_label, |list_item, item_label| {
474 list_item.tooltip(move |_, _| item_label.clone())
475 })
476 .map(|item| {
477 let item = if matches!(source_kind, TaskSourceKind::UserInput)
478 || Some(ix) <= self.divider_index
479 {
480 let task_index = hit.candidate_id;
481 let delete_button = div().child(
482 IconButton::new("delete", IconName::Close)
483 .shape(IconButtonShape::Square)
484 .icon_color(Color::Muted)
485 .size(ButtonSize::None)
486 .icon_size(IconSize::XSmall)
487 .on_click(cx.listener(move |picker, _event, window, cx| {
488 cx.stop_propagation();
489 window.prevent_default();
490
491 picker.delegate.delete_previously_used(task_index, cx);
492 picker.delegate.last_used_candidate_index = picker
493 .delegate
494 .last_used_candidate_index
495 .unwrap_or(0)
496 .checked_sub(1);
497 picker.refresh(window, cx);
498 }))
499 .tooltip(|_, cx| {
500 Tooltip::simple("Delete Previously Scheduled Task", cx)
501 }),
502 );
503 item.end_hover_slot(delete_button)
504 } else {
505 item
506 };
507 item
508 })
509 .toggle_state(selected)
510 .child(highlighted_location.render(window, cx)),
511 )
512 }
513
514 fn confirm_completion(
515 &mut self,
516 _: String,
517 _window: &mut Window,
518 _: &mut Context<Picker<Self>>,
519 ) -> Option<String> {
520 let task_index = self.matches.get(self.selected_index())?.candidate_id;
521 let tasks = self.candidates.as_ref()?;
522 let (_, task) = tasks.get(task_index)?;
523 Some(task.resolved.command_label.clone())
524 }
525
526 fn confirm_input(
527 &mut self,
528 omit_history_entry: bool,
529 window: &mut Window,
530 cx: &mut Context<Picker<Self>>,
531 ) {
532 let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
533 return;
534 };
535
536 if let Some(TaskOverrides {
537 reveal_target: Some(reveal_target),
538 }) = self.task_overrides
539 {
540 task.resolved.reveal_target = reveal_target;
541 }
542 self.workspace
543 .update(cx, |workspace, cx| {
544 workspace.schedule_resolved_task(
545 task_source_kind,
546 task,
547 omit_history_entry,
548 window,
549 cx,
550 )
551 })
552 .ok();
553 cx.emit(DismissEvent);
554 }
555
556 fn separators_after_indices(&self) -> Vec<usize> {
557 if let Some(i) = self.divider_index {
558 vec![i]
559 } else {
560 Vec::new()
561 }
562 }
563 fn render_footer(
564 &self,
565 window: &mut Window,
566 cx: &mut Context<Picker<Self>>,
567 ) -> Option<gpui::AnyElement> {
568 let is_recent_selected = self.divider_index >= Some(self.selected_index);
569 let current_modifiers = window.modifiers();
570 let left_button = if self
571 .task_store
572 .read(cx)
573 .task_inventory()?
574 .read(cx)
575 .last_scheduled_task(None)
576 .is_some()
577 {
578 Some(("Rerun Last Task", Rerun::default().boxed_clone()))
579 } else {
580 None
581 };
582 Some(
583 h_flex()
584 .w_full()
585 .h_8()
586 .p_2()
587 .justify_between()
588 .rounded_b_sm()
589 .bg(cx.theme().colors().ghost_element_selected)
590 .border_t_1()
591 .border_color(cx.theme().colors().border_variant)
592 .child(
593 left_button
594 .map(|(label, action)| {
595 let keybind = KeyBinding::for_action(&*action, window, cx);
596
597 Button::new("edit-current-task", label)
598 .label_size(LabelSize::Small)
599 .when_some(keybind, |this, keybind| this.key_binding(keybind))
600 .on_click(move |_, window, cx| {
601 window.dispatch_action(action.boxed_clone(), cx);
602 })
603 .into_any_element()
604 })
605 .unwrap_or_else(|| h_flex().into_any_element()),
606 )
607 .map(|this| {
608 if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
609 {
610 let action = picker::ConfirmInput {
611 secondary: current_modifiers.secondary(),
612 }
613 .boxed_clone();
614 this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
615 let spawn_oneshot_label = if current_modifiers.secondary() {
616 "Spawn Oneshot Without History"
617 } else {
618 "Spawn Oneshot"
619 };
620
621 Button::new("spawn-onehshot", spawn_oneshot_label)
622 .label_size(LabelSize::Small)
623 .key_binding(keybind)
624 .on_click(move |_, window, cx| {
625 window.dispatch_action(action.boxed_clone(), cx)
626 })
627 }))
628 } else if current_modifiers.secondary() {
629 this.children(
630 KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
631 |keybind| {
632 let label = if is_recent_selected {
633 "Rerun Without History"
634 } else {
635 "Spawn Without History"
636 };
637 Button::new("spawn", label)
638 .label_size(LabelSize::Small)
639 .key_binding(keybind)
640 .on_click(move |_, window, cx| {
641 window.dispatch_action(
642 menu::SecondaryConfirm.boxed_clone(),
643 cx,
644 )
645 })
646 },
647 ),
648 )
649 } else {
650 this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
651 |keybind| {
652 let run_entry_label =
653 if is_recent_selected { "Rerun" } else { "Spawn" };
654
655 Button::new("spawn", run_entry_label)
656 .label_size(LabelSize::Small)
657 .key_binding(keybind)
658 .on_click(|_, window, cx| {
659 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
660 })
661 },
662 ))
663 }
664 })
665 .into_any_element(),
666 )
667 }
668}
669
670fn string_match_candidates<'a>(
671 candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
672 task_modal_type: TaskModal,
673) -> Vec<StringMatchCandidate> {
674 candidates
675 .into_iter()
676 .enumerate()
677 .filter(|(_, (_, _))| task_modal_type == TaskModal::ScriptModal)
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}