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