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