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