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