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