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