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