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