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 util::path;
607 use workspace::CloseInactiveTabsAndPanes;
608
609 use crate::{modal::Spawn, tests::init_test};
610
611 use super::*;
612
613 #[gpui::test]
614 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
615 init_test(cx);
616 let fs = FakeFs::new(cx.executor());
617 fs.insert_tree(
618 path!("/dir"),
619 json!({
620 ".zed": {
621 "tasks.json": r#"[
622 {
623 "label": "example task",
624 "command": "echo",
625 "args": ["4"]
626 },
627 {
628 "label": "another one",
629 "command": "echo",
630 "args": ["55"]
631 },
632 ]"#,
633 },
634 "a.ts": "a"
635 }),
636 )
637 .await;
638
639 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
640 let (workspace, cx) =
641 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
642
643 let tasks_picker = open_spawn_tasks(&workspace, cx);
644 assert_eq!(
645 query(&tasks_picker, cx),
646 "",
647 "Initial query should be empty"
648 );
649 assert_eq!(
650 task_names(&tasks_picker, cx),
651 Vec::<String>::new(),
652 "With no global tasks and no open item, no tasks should be listed"
653 );
654 drop(tasks_picker);
655
656 let _ = workspace
657 .update_in(cx, |workspace, window, cx| {
658 workspace.open_abs_path(PathBuf::from(path!("/dir/a.ts")), true, window, cx)
659 })
660 .await
661 .unwrap();
662 let tasks_picker = open_spawn_tasks(&workspace, cx);
663 assert_eq!(
664 task_names(&tasks_picker, cx),
665 vec!["another one", "example task"],
666 "Initial tasks should be listed in alphabetical order"
667 );
668
669 let query_str = "tas";
670 cx.simulate_input(query_str);
671 assert_eq!(query(&tasks_picker, cx), query_str);
672 assert_eq!(
673 task_names(&tasks_picker, cx),
674 vec!["example task"],
675 "Only one task should match the query {query_str}"
676 );
677
678 cx.dispatch_action(picker::ConfirmCompletion);
679 assert_eq!(
680 query(&tasks_picker, cx),
681 "echo 4",
682 "Query should be set to the selected task's command"
683 );
684 assert_eq!(
685 task_names(&tasks_picker, cx),
686 Vec::<String>::new(),
687 "No task should be listed"
688 );
689 cx.dispatch_action(picker::ConfirmInput { secondary: false });
690
691 let tasks_picker = open_spawn_tasks(&workspace, cx);
692 assert_eq!(
693 query(&tasks_picker, cx),
694 "",
695 "Query should be reset after confirming"
696 );
697 assert_eq!(
698 task_names(&tasks_picker, cx),
699 vec!["echo 4", "another one", "example task"],
700 "New oneshot task should be listed first"
701 );
702
703 let query_str = "echo 4";
704 cx.simulate_input(query_str);
705 assert_eq!(query(&tasks_picker, cx), query_str);
706 assert_eq!(
707 task_names(&tasks_picker, cx),
708 vec!["echo 4"],
709 "New oneshot should match custom command query"
710 );
711
712 cx.dispatch_action(picker::ConfirmInput { secondary: false });
713 let tasks_picker = open_spawn_tasks(&workspace, cx);
714 assert_eq!(
715 query(&tasks_picker, cx),
716 "",
717 "Query should be reset after confirming"
718 );
719 assert_eq!(
720 task_names(&tasks_picker, cx),
721 vec![query_str, "another one", "example task"],
722 "Last recently used one show task should be listed first"
723 );
724
725 cx.dispatch_action(picker::ConfirmCompletion);
726 assert_eq!(
727 query(&tasks_picker, cx),
728 query_str,
729 "Query should be set to the custom task's name"
730 );
731 assert_eq!(
732 task_names(&tasks_picker, cx),
733 vec![query_str],
734 "Only custom task should be listed"
735 );
736
737 let query_str = "0";
738 cx.simulate_input(query_str);
739 assert_eq!(query(&tasks_picker, cx), "echo 40");
740 assert_eq!(
741 task_names(&tasks_picker, cx),
742 Vec::<String>::new(),
743 "New oneshot should not match any command query"
744 );
745
746 cx.dispatch_action(picker::ConfirmInput { secondary: true });
747 let tasks_picker = open_spawn_tasks(&workspace, cx);
748 assert_eq!(
749 query(&tasks_picker, cx),
750 "",
751 "Query should be reset after confirming"
752 );
753 assert_eq!(
754 task_names(&tasks_picker, cx),
755 vec!["echo 4", "another one", "example task"],
756 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
757 );
758
759 cx.dispatch_action(Spawn::ByName {
760 task_name: "example task".to_string(),
761 reveal_target: None,
762 });
763 let tasks_picker = workspace.update(cx, |workspace, cx| {
764 workspace
765 .active_modal::<TasksModal>(cx)
766 .unwrap()
767 .read(cx)
768 .picker
769 .clone()
770 });
771 assert_eq!(
772 task_names(&tasks_picker, cx),
773 vec!["echo 4", "another one", "example task"],
774 );
775 }
776
777 #[gpui::test]
778 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
779 init_test(cx);
780 let fs = FakeFs::new(cx.executor());
781 fs.insert_tree(
782 path!("/dir"),
783 json!({
784 ".zed": {
785 "tasks.json": r#"[
786 {
787 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
788 "command": "echo",
789 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
790 },
791 {
792 "label": "opened now: $ZED_WORKTREE_ROOT",
793 "command": "echo",
794 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
795 }
796 ]"#,
797 },
798 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
799 "file_with.odd_extension": "b",
800 }),
801 )
802 .await;
803
804 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
805 let (workspace, cx) =
806 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
807
808 let tasks_picker = open_spawn_tasks(&workspace, cx);
809 assert_eq!(
810 task_names(&tasks_picker, cx),
811 Vec::<String>::new(),
812 "Should list no file or worktree context-dependent when no file is open"
813 );
814 tasks_picker.update(cx, |_, cx| {
815 cx.emit(DismissEvent);
816 });
817 drop(tasks_picker);
818 cx.executor().run_until_parked();
819
820 let _ = workspace
821 .update_in(cx, |workspace, window, cx| {
822 workspace.open_abs_path(
823 PathBuf::from(path!("/dir/file_with.odd_extension")),
824 true,
825 window,
826 cx,
827 )
828 })
829 .await
830 .unwrap();
831 cx.executor().run_until_parked();
832 let tasks_picker = open_spawn_tasks(&workspace, cx);
833 assert_eq!(
834 task_names(&tasks_picker, cx),
835 vec![
836 concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
837 concat!("opened now: ", path!("/dir")).to_string(),
838 ],
839 "Second opened buffer should fill the context, labels should be trimmed if long enough"
840 );
841 tasks_picker.update(cx, |_, cx| {
842 cx.emit(DismissEvent);
843 });
844 drop(tasks_picker);
845 cx.executor().run_until_parked();
846
847 let second_item = workspace
848 .update_in(cx, |workspace, window, cx| {
849 workspace.open_abs_path(
850 PathBuf::from(path!("/dir/file_without_extension")),
851 true,
852 window,
853 cx,
854 )
855 })
856 .await
857 .unwrap();
858
859 let editor = cx
860 .update(|_window, cx| second_item.act_as::<Editor>(cx))
861 .unwrap();
862 editor.update_in(cx, |editor, window, cx| {
863 editor.change_selections(None, window, cx, |s| {
864 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
865 })
866 });
867 cx.executor().run_until_parked();
868 let tasks_picker = open_spawn_tasks(&workspace, cx);
869 assert_eq!(
870 task_names(&tasks_picker, cx),
871 vec![
872 concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
873 concat!("opened now: ", path!("/dir")).to_string(),
874 ],
875 "Opened buffer should fill the context, labels should be trimmed if long enough"
876 );
877 tasks_picker.update(cx, |_, cx| {
878 cx.emit(DismissEvent);
879 });
880 drop(tasks_picker);
881 cx.executor().run_until_parked();
882 }
883
884 #[gpui::test]
885 async fn test_language_task_filtering(cx: &mut TestAppContext) {
886 init_test(cx);
887 let fs = FakeFs::new(cx.executor());
888 fs.insert_tree(
889 path!("/dir"),
890 json!({
891 "a1.ts": "// a1",
892 "a2.ts": "// a2",
893 "b.rs": "// b",
894 }),
895 )
896 .await;
897
898 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
899 project.read_with(cx, |project, _| {
900 let language_registry = project.languages();
901 language_registry.add(Arc::new(
902 Language::new(
903 LanguageConfig {
904 name: "TypeScript".into(),
905 matcher: LanguageMatcher {
906 path_suffixes: vec!["ts".to_string()],
907 ..LanguageMatcher::default()
908 },
909 ..LanguageConfig::default()
910 },
911 None,
912 )
913 .with_context_provider(Some(Arc::new(
914 ContextProviderWithTasks::new(TaskTemplates(vec![
915 TaskTemplate {
916 label: "Task without variables".to_string(),
917 command: "npm run clean".to_string(),
918 ..TaskTemplate::default()
919 },
920 TaskTemplate {
921 label: "TypeScript task from file $ZED_FILE".to_string(),
922 command: "npm run build".to_string(),
923 ..TaskTemplate::default()
924 },
925 TaskTemplate {
926 label: "Another task from file $ZED_FILE".to_string(),
927 command: "npm run lint".to_string(),
928 ..TaskTemplate::default()
929 },
930 ])),
931 ))),
932 ));
933 language_registry.add(Arc::new(
934 Language::new(
935 LanguageConfig {
936 name: "Rust".into(),
937 matcher: LanguageMatcher {
938 path_suffixes: vec!["rs".to_string()],
939 ..LanguageMatcher::default()
940 },
941 ..LanguageConfig::default()
942 },
943 None,
944 )
945 .with_context_provider(Some(Arc::new(
946 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
947 label: "Rust task".to_string(),
948 command: "cargo check".into(),
949 ..TaskTemplate::default()
950 }])),
951 ))),
952 ));
953 });
954 let (workspace, cx) =
955 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
956
957 let _ts_file_1 = workspace
958 .update_in(cx, |workspace, window, cx| {
959 workspace.open_abs_path(PathBuf::from(path!("/dir/a1.ts")), true, window, cx)
960 })
961 .await
962 .unwrap();
963 let tasks_picker = open_spawn_tasks(&workspace, cx);
964 assert_eq!(
965 task_names(&tasks_picker, cx),
966 vec![
967 concat!("Another task from file ", path!("/dir/a1.ts")),
968 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
969 "Task without variables",
970 ],
971 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
972 );
973
974 emulate_task_schedule(
975 tasks_picker,
976 &project,
977 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
978 cx,
979 );
980
981 let tasks_picker = open_spawn_tasks(&workspace, cx);
982 assert_eq!(
983 task_names(&tasks_picker, cx),
984 vec![
985 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
986 concat!("Another task from file ", path!("/dir/a1.ts")),
987 "Task without variables",
988 ],
989 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
990 Tasks with the same labels and context are deduplicated."
991 );
992 tasks_picker.update(cx, |_, cx| {
993 cx.emit(DismissEvent);
994 });
995 drop(tasks_picker);
996 cx.executor().run_until_parked();
997
998 let _ts_file_2 = workspace
999 .update_in(cx, |workspace, window, cx| {
1000 workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
1001 })
1002 .await
1003 .unwrap();
1004 let tasks_picker = open_spawn_tasks(&workspace, cx);
1005 assert_eq!(
1006 task_names(&tasks_picker, cx),
1007 vec![
1008 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1009 concat!("Another task from file ", path!("/dir/a2.ts")),
1010 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1011 "Task without variables",
1012 ],
1013 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1014 );
1015 tasks_picker.update(cx, |_, cx| {
1016 cx.emit(DismissEvent);
1017 });
1018 drop(tasks_picker);
1019 cx.executor().run_until_parked();
1020
1021 let _rs_file = workspace
1022 .update_in(cx, |workspace, window, cx| {
1023 workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, window, cx)
1024 })
1025 .await
1026 .unwrap();
1027 let tasks_picker = open_spawn_tasks(&workspace, cx);
1028 assert_eq!(
1029 task_names(&tasks_picker, cx),
1030 vec!["Rust task"],
1031 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1032 );
1033
1034 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1035 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1036 let _ts_file_2 = workspace
1037 .update_in(cx, |workspace, window, cx| {
1038 workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
1039 })
1040 .await
1041 .unwrap();
1042 let tasks_picker = open_spawn_tasks(&workspace, cx);
1043 assert_eq!(
1044 task_names(&tasks_picker, cx),
1045 vec![
1046 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1047 concat!("Another task from file ", path!("/dir/a2.ts")),
1048 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1049 "Task without variables",
1050 ],
1051 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1052 same TS spawn history should be restored"
1053 );
1054 }
1055
1056 fn emulate_task_schedule(
1057 tasks_picker: Entity<Picker<TasksModalDelegate>>,
1058 project: &Entity<Project>,
1059 scheduled_task_label: &str,
1060 cx: &mut VisualTestContext,
1061 ) {
1062 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1063 tasks_picker
1064 .delegate
1065 .candidates
1066 .iter()
1067 .flatten()
1068 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1069 .cloned()
1070 .unwrap()
1071 });
1072 project.update(cx, |project, cx| {
1073 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1074 task_inventory.update(cx, |inventory, _| {
1075 let (kind, task) = scheduled_task;
1076 inventory.task_scheduled(kind, task);
1077 });
1078 }
1079 });
1080 tasks_picker.update(cx, |_, cx| {
1081 cx.emit(DismissEvent);
1082 });
1083 drop(tasks_picker);
1084 cx.executor().run_until_parked()
1085 }
1086
1087 fn open_spawn_tasks(
1088 workspace: &Entity<Workspace>,
1089 cx: &mut VisualTestContext,
1090 ) -> Entity<Picker<TasksModalDelegate>> {
1091 cx.dispatch_action(Spawn::modal());
1092 workspace.update(cx, |workspace, cx| {
1093 workspace
1094 .active_modal::<TasksModal>(cx)
1095 .expect("no task modal after `Spawn` action was dispatched")
1096 .read(cx)
1097 .picker
1098 .clone()
1099 })
1100 }
1101
1102 fn query(
1103 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1104 cx: &mut VisualTestContext,
1105 ) -> String {
1106 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1107 }
1108
1109 fn task_names(
1110 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1111 cx: &mut VisualTestContext,
1112 ) -> Vec<String> {
1113 spawn_tasks.update(cx, |spawn_tasks, _| {
1114 spawn_tasks
1115 .delegate
1116 .matches
1117 .iter()
1118 .map(|hit| hit.string.clone())
1119 .collect::<Vec<_>>()
1120 })
1121 }
1122}