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