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 DebugRequestType, 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, tasks::schedule_resolved_task};
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 _: &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(config) if config.locator.is_none() => {
364 let Some(config): Option<DebugTaskDefinition> = task
365 .resolved_debug_adapter_config()
366 .and_then(|config| config.try_into().ok())
367 else {
368 return;
369 };
370
371 match &config.request {
372 DebugRequestType::Attach(attach_config)
373 if attach_config.process_id.is_none() =>
374 {
375 cx.emit(ShowAttachModal {
376 debug_config: config.clone(),
377 });
378 return;
379 }
380 _ => {
381 self.workspace
382 .update(cx, |workspace, cx| {
383 workspace.project().update(cx, |project, cx| {
384 project
385 .start_debug_session(config.into(), cx)
386 .detach_and_log_err(cx);
387 });
388 })
389 .ok();
390 }
391 }
392 }
393 _ => {
394 self.workspace
395 .update(cx, |workspace, cx| {
396 schedule_resolved_task(
397 workspace,
398 task_source_kind,
399 task,
400 omit_history_entry,
401 cx,
402 );
403 })
404 .ok();
405 }
406 };
407
408 cx.emit(DismissEvent);
409 }
410
411 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
412 cx.emit(DismissEvent);
413 }
414
415 fn render_match(
416 &self,
417 ix: usize,
418 selected: bool,
419 window: &mut Window,
420 cx: &mut Context<picker::Picker<Self>>,
421 ) -> Option<Self::ListItem> {
422 let candidates = self.candidates.as_ref()?;
423 let hit = &self.matches[ix];
424 let (source_kind, resolved_task) = &candidates.get(hit.candidate_id)?;
425 let template = resolved_task.original_task();
426 let display_label = resolved_task.display_label();
427
428 let mut tooltip_label_text = if display_label != &template.label {
429 resolved_task.resolved_label.clone()
430 } else {
431 String::new()
432 };
433 if let Some(resolved) = resolved_task.resolved.as_ref() {
434 if resolved.command_label != display_label
435 && resolved.command_label != resolved_task.resolved_label
436 {
437 if !tooltip_label_text.trim().is_empty() {
438 tooltip_label_text.push('\n');
439 }
440 tooltip_label_text.push_str(&resolved.command_label);
441 }
442 }
443 if template.tags.len() > 0 {
444 tooltip_label_text.push('\n');
445 tooltip_label_text.push_str(
446 template
447 .tags
448 .iter()
449 .map(|tag| format!("\n#{}", tag))
450 .collect::<Vec<_>>()
451 .join("")
452 .as_str(),
453 );
454 }
455 let tooltip_label = if tooltip_label_text.trim().is_empty() {
456 None
457 } else {
458 Some(Tooltip::simple(tooltip_label_text, cx))
459 };
460
461 let highlighted_location = HighlightedMatch {
462 text: hit.string.clone(),
463 highlight_positions: hit.positions.clone(),
464 char_count: hit.string.chars().count(),
465 color: Color::Default,
466 };
467 let icon = match source_kind {
468 TaskSourceKind::Lsp(..) => Some(Icon::new(IconName::Bolt)),
469 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
470 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
471 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
472 TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
473 .get_icon_for_type(&name.to_lowercase(), cx)
474 .map(Icon::from_path),
475 }
476 .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
477 let history_run_icon = if Some(ix) <= self.divider_index {
478 Some(
479 Icon::new(IconName::HistoryRerun)
480 .color(Color::Muted)
481 .size(IconSize::Small)
482 .into_any_element(),
483 )
484 } else {
485 Some(
486 v_flex()
487 .flex_none()
488 .size(IconSize::Small.rems())
489 .into_any_element(),
490 )
491 };
492
493 Some(
494 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
495 .inset(true)
496 .start_slot::<Icon>(icon)
497 .end_slot::<AnyElement>(
498 h_flex()
499 .gap_1()
500 .child(Label::new(truncate_and_trailoff(
501 &template
502 .tags
503 .iter()
504 .map(|tag| format!("#{}", tag))
505 .collect::<Vec<_>>()
506 .join(" "),
507 MAX_TAGS_LINE_LEN,
508 )))
509 .flex_none()
510 .child(history_run_icon.unwrap())
511 .into_any_element(),
512 )
513 .spacing(ListItemSpacing::Sparse)
514 .when_some(tooltip_label, |list_item, item_label| {
515 list_item.tooltip(move |_, _| item_label.clone())
516 })
517 .map(|item| {
518 let item = if matches!(source_kind, TaskSourceKind::UserInput)
519 || Some(ix) <= self.divider_index
520 {
521 let task_index = hit.candidate_id;
522 let delete_button = div().child(
523 IconButton::new("delete", IconName::Close)
524 .shape(IconButtonShape::Square)
525 .icon_color(Color::Muted)
526 .size(ButtonSize::None)
527 .icon_size(IconSize::XSmall)
528 .on_click(cx.listener(move |picker, _event, window, cx| {
529 cx.stop_propagation();
530 window.prevent_default();
531
532 picker.delegate.delete_previously_used(task_index, cx);
533 picker.delegate.last_used_candidate_index = picker
534 .delegate
535 .last_used_candidate_index
536 .unwrap_or(0)
537 .checked_sub(1);
538 picker.refresh(window, cx);
539 }))
540 .tooltip(|_, cx| {
541 Tooltip::simple("Delete Previously Scheduled Task", cx)
542 }),
543 );
544 item.end_hover_slot(delete_button)
545 } else {
546 item
547 };
548 item
549 })
550 .toggle_state(selected)
551 .child(highlighted_location.render(window, cx)),
552 )
553 }
554
555 fn confirm_completion(
556 &mut self,
557 _: String,
558 _window: &mut Window,
559 _: &mut Context<Picker<Self>>,
560 ) -> Option<String> {
561 let task_index = self.matches.get(self.selected_index())?.candidate_id;
562 let tasks = self.candidates.as_ref()?;
563 let (_, task) = tasks.get(task_index)?;
564 Some(task.resolved.as_ref()?.command_label.clone())
565 }
566
567 fn confirm_input(
568 &mut self,
569 omit_history_entry: bool,
570 _: &mut Window,
571 cx: &mut Context<Picker<Self>>,
572 ) {
573 let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
574 return;
575 };
576
577 if let Some(TaskOverrides {
578 reveal_target: Some(reveal_target),
579 }) = self.task_overrides
580 {
581 if let Some(resolved_task) = &mut task.resolved {
582 resolved_task.reveal_target = reveal_target;
583 }
584 }
585 self.workspace
586 .update(cx, |workspace, cx| {
587 match task.task_type() {
588 TaskType::Script => schedule_resolved_task(
589 workspace,
590 task_source_kind,
591 task,
592 omit_history_entry,
593 cx,
594 ),
595 // todo(debugger): Should create a schedule_resolved_debug_task function
596 // This would allow users to access to debug history and other issues
597 TaskType::Debug(debug_args) => {
598 let Some(debug_config) = task.resolved_debug_adapter_config() else {
599 // todo(debugger) log an error, this should never happen
600 return;
601 };
602
603 if debug_args.locator.is_some() {
604 schedule_resolved_task(
605 workspace,
606 task_source_kind,
607 task,
608 omit_history_entry,
609 cx,
610 );
611 } else {
612 workspace.project().update(cx, |project, cx| {
613 project
614 .start_debug_session(debug_config, cx)
615 .detach_and_log_err(cx);
616 });
617 }
618 }
619 };
620 })
621 .ok();
622 cx.emit(DismissEvent);
623 }
624
625 fn separators_after_indices(&self) -> Vec<usize> {
626 if let Some(i) = self.divider_index {
627 vec![i]
628 } else {
629 Vec::new()
630 }
631 }
632 fn render_footer(
633 &self,
634 window: &mut Window,
635 cx: &mut Context<Picker<Self>>,
636 ) -> Option<gpui::AnyElement> {
637 let is_recent_selected = self.divider_index >= Some(self.selected_index);
638 let current_modifiers = window.modifiers();
639 let left_button = if self
640 .task_store
641 .read(cx)
642 .task_inventory()?
643 .read(cx)
644 .last_scheduled_task(None)
645 .is_some()
646 {
647 Some(("Rerun Last Task", Rerun::default().boxed_clone()))
648 } else {
649 None
650 };
651 Some(
652 h_flex()
653 .w_full()
654 .h_8()
655 .p_2()
656 .justify_between()
657 .rounded_b_sm()
658 .bg(cx.theme().colors().ghost_element_selected)
659 .border_t_1()
660 .border_color(cx.theme().colors().border_variant)
661 .child(
662 left_button
663 .map(|(label, action)| {
664 let keybind = KeyBinding::for_action(&*action, window, cx);
665
666 Button::new("edit-current-task", label)
667 .label_size(LabelSize::Small)
668 .when_some(keybind, |this, keybind| this.key_binding(keybind))
669 .on_click(move |_, window, cx| {
670 window.dispatch_action(action.boxed_clone(), cx);
671 })
672 .into_any_element()
673 })
674 .unwrap_or_else(|| h_flex().into_any_element()),
675 )
676 .map(|this| {
677 if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
678 {
679 let action = picker::ConfirmInput {
680 secondary: current_modifiers.secondary(),
681 }
682 .boxed_clone();
683 this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
684 let spawn_oneshot_label = if current_modifiers.secondary() {
685 "Spawn Oneshot Without History"
686 } else {
687 "Spawn Oneshot"
688 };
689
690 Button::new("spawn-onehshot", spawn_oneshot_label)
691 .label_size(LabelSize::Small)
692 .key_binding(keybind)
693 .on_click(move |_, window, cx| {
694 window.dispatch_action(action.boxed_clone(), cx)
695 })
696 }))
697 } else if current_modifiers.secondary() {
698 this.children(
699 KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
700 |keybind| {
701 let label = if is_recent_selected {
702 "Rerun Without History"
703 } else {
704 "Spawn Without History"
705 };
706 Button::new("spawn", label)
707 .label_size(LabelSize::Small)
708 .key_binding(keybind)
709 .on_click(move |_, window, cx| {
710 window.dispatch_action(
711 menu::SecondaryConfirm.boxed_clone(),
712 cx,
713 )
714 })
715 },
716 ),
717 )
718 } else {
719 this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
720 |keybind| {
721 let run_entry_label =
722 if is_recent_selected { "Rerun" } else { "Spawn" };
723
724 Button::new("spawn", run_entry_label)
725 .label_size(LabelSize::Small)
726 .key_binding(keybind)
727 .on_click(|_, window, cx| {
728 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
729 })
730 },
731 ))
732 }
733 })
734 .into_any_element(),
735 )
736 }
737}
738
739fn string_match_candidates<'a>(
740 candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
741 task_modal_type: TaskModal,
742) -> Vec<StringMatchCandidate> {
743 candidates
744 .into_iter()
745 .enumerate()
746 .filter(|(_, (_, candidate))| match candidate.task_type() {
747 TaskType::Script => task_modal_type == TaskModal::ScriptModal,
748 TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal,
749 })
750 .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
751 .collect()
752}
753
754#[cfg(test)]
755mod tests {
756 use std::{path::PathBuf, sync::Arc};
757
758 use editor::Editor;
759 use gpui::{TestAppContext, VisualTestContext};
760 use language::{Language, LanguageConfig, LanguageMatcher, Point};
761 use project::{ContextProviderWithTasks, FakeFs, Project};
762 use serde_json::json;
763 use task::TaskTemplates;
764 use util::path;
765 use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
766
767 use crate::{modal::Spawn, tests::init_test};
768
769 use super::*;
770
771 #[gpui::test]
772 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
773 init_test(cx);
774 let fs = FakeFs::new(cx.executor());
775 fs.insert_tree(
776 path!("/dir"),
777 json!({
778 ".zed": {
779 "tasks.json": r#"[
780 {
781 "label": "example task",
782 "command": "echo",
783 "args": ["4"]
784 },
785 {
786 "label": "another one",
787 "command": "echo",
788 "args": ["55"]
789 },
790 ]"#,
791 },
792 "a.ts": "a"
793 }),
794 )
795 .await;
796
797 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
798 let (workspace, cx) =
799 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
800
801 let tasks_picker = open_spawn_tasks(&workspace, cx);
802 assert_eq!(
803 query(&tasks_picker, cx),
804 "",
805 "Initial query should be empty"
806 );
807 assert_eq!(
808 task_names(&tasks_picker, cx),
809 vec!["another one", "example task"],
810 "With no global tasks and no open item, a single worktree should be used and its tasks listed"
811 );
812 drop(tasks_picker);
813
814 let _ = workspace
815 .update_in(cx, |workspace, window, cx| {
816 workspace.open_abs_path(
817 PathBuf::from(path!("/dir/a.ts")),
818 OpenOptions {
819 visible: Some(OpenVisible::All),
820 ..Default::default()
821 },
822 window,
823 cx,
824 )
825 })
826 .await
827 .unwrap();
828 let tasks_picker = open_spawn_tasks(&workspace, cx);
829 assert_eq!(
830 task_names(&tasks_picker, cx),
831 vec!["another one", "example task"],
832 "Initial tasks should be listed in alphabetical order"
833 );
834
835 let query_str = "tas";
836 cx.simulate_input(query_str);
837 assert_eq!(query(&tasks_picker, cx), query_str);
838 assert_eq!(
839 task_names(&tasks_picker, cx),
840 vec!["example task"],
841 "Only one task should match the query {query_str}"
842 );
843
844 cx.dispatch_action(picker::ConfirmCompletion);
845 assert_eq!(
846 query(&tasks_picker, cx),
847 "echo 4",
848 "Query should be set to the selected task's command"
849 );
850 assert_eq!(
851 task_names(&tasks_picker, cx),
852 Vec::<String>::new(),
853 "No task should be listed"
854 );
855 cx.dispatch_action(picker::ConfirmInput { secondary: false });
856
857 let tasks_picker = open_spawn_tasks(&workspace, cx);
858 assert_eq!(
859 query(&tasks_picker, cx),
860 "",
861 "Query should be reset after confirming"
862 );
863 assert_eq!(
864 task_names(&tasks_picker, cx),
865 vec!["echo 4", "another one", "example task"],
866 "New oneshot task should be listed first"
867 );
868
869 let query_str = "echo 4";
870 cx.simulate_input(query_str);
871 assert_eq!(query(&tasks_picker, cx), query_str);
872 assert_eq!(
873 task_names(&tasks_picker, cx),
874 vec!["echo 4"],
875 "New oneshot should match custom command query"
876 );
877
878 cx.dispatch_action(picker::ConfirmInput { secondary: false });
879 let tasks_picker = open_spawn_tasks(&workspace, cx);
880 assert_eq!(
881 query(&tasks_picker, cx),
882 "",
883 "Query should be reset after confirming"
884 );
885 assert_eq!(
886 task_names(&tasks_picker, cx),
887 vec![query_str, "another one", "example task"],
888 "Last recently used one show task should be listed first"
889 );
890
891 cx.dispatch_action(picker::ConfirmCompletion);
892 assert_eq!(
893 query(&tasks_picker, cx),
894 query_str,
895 "Query should be set to the custom task's name"
896 );
897 assert_eq!(
898 task_names(&tasks_picker, cx),
899 vec![query_str],
900 "Only custom task should be listed"
901 );
902
903 let query_str = "0";
904 cx.simulate_input(query_str);
905 assert_eq!(query(&tasks_picker, cx), "echo 40");
906 assert_eq!(
907 task_names(&tasks_picker, cx),
908 Vec::<String>::new(),
909 "New oneshot should not match any command query"
910 );
911
912 cx.dispatch_action(picker::ConfirmInput { secondary: true });
913 let tasks_picker = open_spawn_tasks(&workspace, cx);
914 assert_eq!(
915 query(&tasks_picker, cx),
916 "",
917 "Query should be reset after confirming"
918 );
919 assert_eq!(
920 task_names(&tasks_picker, cx),
921 vec!["echo 4", "another one", "example task"],
922 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
923 );
924
925 cx.dispatch_action(Spawn::ByName {
926 task_name: "example task".to_string(),
927 reveal_target: None,
928 });
929 let tasks_picker = workspace.update(cx, |workspace, cx| {
930 workspace
931 .active_modal::<TasksModal>(cx)
932 .unwrap()
933 .read(cx)
934 .picker
935 .clone()
936 });
937 assert_eq!(
938 task_names(&tasks_picker, cx),
939 vec!["echo 4", "another one", "example task"],
940 );
941 }
942
943 #[gpui::test]
944 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
945 init_test(cx);
946 let fs = FakeFs::new(cx.executor());
947 fs.insert_tree(
948 path!("/dir"),
949 json!({
950 ".zed": {
951 "tasks.json": r#"[
952 {
953 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
954 "command": "echo",
955 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
956 },
957 {
958 "label": "opened now: $ZED_WORKTREE_ROOT",
959 "command": "echo",
960 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
961 }
962 ]"#,
963 },
964 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
965 "file_with.odd_extension": "b",
966 }),
967 )
968 .await;
969
970 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
971 let (workspace, cx) =
972 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
973
974 let tasks_picker = open_spawn_tasks(&workspace, cx);
975 assert_eq!(
976 task_names(&tasks_picker, cx),
977 vec![concat!("opened now: ", path!("/dir")).to_string()],
978 "When no file is open for a single worktree, should autodetect all worktree-related tasks"
979 );
980 tasks_picker.update(cx, |_, cx| {
981 cx.emit(DismissEvent);
982 });
983 drop(tasks_picker);
984 cx.executor().run_until_parked();
985
986 let _ = workspace
987 .update_in(cx, |workspace, window, cx| {
988 workspace.open_abs_path(
989 PathBuf::from(path!("/dir/file_with.odd_extension")),
990 OpenOptions {
991 visible: Some(OpenVisible::All),
992 ..Default::default()
993 },
994 window,
995 cx,
996 )
997 })
998 .await
999 .unwrap();
1000 cx.executor().run_until_parked();
1001 let tasks_picker = open_spawn_tasks(&workspace, cx);
1002 assert_eq!(
1003 task_names(&tasks_picker, cx),
1004 vec![
1005 concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
1006 concat!("opened now: ", path!("/dir")).to_string(),
1007 ],
1008 "Second opened buffer should fill the context, labels should be trimmed if long enough"
1009 );
1010 tasks_picker.update(cx, |_, cx| {
1011 cx.emit(DismissEvent);
1012 });
1013 drop(tasks_picker);
1014 cx.executor().run_until_parked();
1015
1016 let second_item = workspace
1017 .update_in(cx, |workspace, window, cx| {
1018 workspace.open_abs_path(
1019 PathBuf::from(path!("/dir/file_without_extension")),
1020 OpenOptions {
1021 visible: Some(OpenVisible::All),
1022 ..Default::default()
1023 },
1024 window,
1025 cx,
1026 )
1027 })
1028 .await
1029 .unwrap();
1030
1031 let editor = cx
1032 .update(|_window, cx| second_item.act_as::<Editor>(cx))
1033 .unwrap();
1034 editor.update_in(cx, |editor, window, cx| {
1035 editor.change_selections(None, window, cx, |s| {
1036 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1037 })
1038 });
1039 cx.executor().run_until_parked();
1040 let tasks_picker = open_spawn_tasks(&workspace, cx);
1041 assert_eq!(
1042 task_names(&tasks_picker, cx),
1043 vec![
1044 concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1045 concat!("opened now: ", path!("/dir")).to_string(),
1046 ],
1047 "Opened buffer should fill the context, labels should be trimmed if long enough"
1048 );
1049 tasks_picker.update(cx, |_, cx| {
1050 cx.emit(DismissEvent);
1051 });
1052 drop(tasks_picker);
1053 cx.executor().run_until_parked();
1054 }
1055
1056 #[gpui::test]
1057 async fn test_language_task_filtering(cx: &mut TestAppContext) {
1058 init_test(cx);
1059 let fs = FakeFs::new(cx.executor());
1060 fs.insert_tree(
1061 path!("/dir"),
1062 json!({
1063 "a1.ts": "// a1",
1064 "a2.ts": "// a2",
1065 "b.rs": "// b",
1066 }),
1067 )
1068 .await;
1069
1070 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1071 project.read_with(cx, |project, _| {
1072 let language_registry = project.languages();
1073 language_registry.add(Arc::new(
1074 Language::new(
1075 LanguageConfig {
1076 name: "TypeScript".into(),
1077 matcher: LanguageMatcher {
1078 path_suffixes: vec!["ts".to_string()],
1079 ..LanguageMatcher::default()
1080 },
1081 ..LanguageConfig::default()
1082 },
1083 None,
1084 )
1085 .with_context_provider(Some(Arc::new(
1086 ContextProviderWithTasks::new(TaskTemplates(vec![
1087 TaskTemplate {
1088 label: "Task without variables".to_string(),
1089 command: "npm run clean".to_string(),
1090 ..TaskTemplate::default()
1091 },
1092 TaskTemplate {
1093 label: "TypeScript task from file $ZED_FILE".to_string(),
1094 command: "npm run build".to_string(),
1095 ..TaskTemplate::default()
1096 },
1097 TaskTemplate {
1098 label: "Another task from file $ZED_FILE".to_string(),
1099 command: "npm run lint".to_string(),
1100 ..TaskTemplate::default()
1101 },
1102 ])),
1103 ))),
1104 ));
1105 language_registry.add(Arc::new(
1106 Language::new(
1107 LanguageConfig {
1108 name: "Rust".into(),
1109 matcher: LanguageMatcher {
1110 path_suffixes: vec!["rs".to_string()],
1111 ..LanguageMatcher::default()
1112 },
1113 ..LanguageConfig::default()
1114 },
1115 None,
1116 )
1117 .with_context_provider(Some(Arc::new(
1118 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1119 label: "Rust task".to_string(),
1120 command: "cargo check".into(),
1121 ..TaskTemplate::default()
1122 }])),
1123 ))),
1124 ));
1125 });
1126 let (workspace, cx) =
1127 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1128
1129 let _ts_file_1 = workspace
1130 .update_in(cx, |workspace, window, cx| {
1131 workspace.open_abs_path(
1132 PathBuf::from(path!("/dir/a1.ts")),
1133 OpenOptions {
1134 visible: Some(OpenVisible::All),
1135 ..Default::default()
1136 },
1137 window,
1138 cx,
1139 )
1140 })
1141 .await
1142 .unwrap();
1143 let tasks_picker = open_spawn_tasks(&workspace, cx);
1144 assert_eq!(
1145 task_names(&tasks_picker, cx),
1146 vec![
1147 concat!("Another task from file ", path!("/dir/a1.ts")),
1148 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1149 "Task without variables",
1150 ],
1151 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1152 );
1153
1154 emulate_task_schedule(
1155 tasks_picker,
1156 &project,
1157 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1158 cx,
1159 );
1160
1161 let tasks_picker = open_spawn_tasks(&workspace, cx);
1162 assert_eq!(
1163 task_names(&tasks_picker, cx),
1164 vec![
1165 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1166 concat!("Another task from file ", path!("/dir/a1.ts")),
1167 "Task without variables",
1168 ],
1169 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1170 Tasks with the same labels and context are deduplicated."
1171 );
1172 tasks_picker.update(cx, |_, cx| {
1173 cx.emit(DismissEvent);
1174 });
1175 drop(tasks_picker);
1176 cx.executor().run_until_parked();
1177
1178 let _ts_file_2 = workspace
1179 .update_in(cx, |workspace, window, cx| {
1180 workspace.open_abs_path(
1181 PathBuf::from(path!("/dir/a2.ts")),
1182 OpenOptions {
1183 visible: Some(OpenVisible::All),
1184 ..Default::default()
1185 },
1186 window,
1187 cx,
1188 )
1189 })
1190 .await
1191 .unwrap();
1192 let tasks_picker = open_spawn_tasks(&workspace, cx);
1193 assert_eq!(
1194 task_names(&tasks_picker, cx),
1195 vec![
1196 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1197 concat!("Another task from file ", path!("/dir/a2.ts")),
1198 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1199 "Task without variables",
1200 ],
1201 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1202 );
1203 tasks_picker.update(cx, |_, cx| {
1204 cx.emit(DismissEvent);
1205 });
1206 drop(tasks_picker);
1207 cx.executor().run_until_parked();
1208
1209 let _rs_file = workspace
1210 .update_in(cx, |workspace, window, cx| {
1211 workspace.open_abs_path(
1212 PathBuf::from(path!("/dir/b.rs")),
1213 OpenOptions {
1214 visible: Some(OpenVisible::All),
1215 ..Default::default()
1216 },
1217 window,
1218 cx,
1219 )
1220 })
1221 .await
1222 .unwrap();
1223 let tasks_picker = open_spawn_tasks(&workspace, cx);
1224 assert_eq!(
1225 task_names(&tasks_picker, cx),
1226 vec!["Rust task"],
1227 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1228 );
1229
1230 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1231 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1232 let _ts_file_2 = workspace
1233 .update_in(cx, |workspace, window, cx| {
1234 workspace.open_abs_path(
1235 PathBuf::from(path!("/dir/a2.ts")),
1236 OpenOptions {
1237 visible: Some(OpenVisible::All),
1238 ..Default::default()
1239 },
1240 window,
1241 cx,
1242 )
1243 })
1244 .await
1245 .unwrap();
1246 let tasks_picker = open_spawn_tasks(&workspace, cx);
1247 assert_eq!(
1248 task_names(&tasks_picker, cx),
1249 vec![
1250 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1251 concat!("Another task from file ", path!("/dir/a2.ts")),
1252 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1253 "Task without variables",
1254 ],
1255 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1256 same TS spawn history should be restored"
1257 );
1258 }
1259
1260 fn emulate_task_schedule(
1261 tasks_picker: Entity<Picker<TasksModalDelegate>>,
1262 project: &Entity<Project>,
1263 scheduled_task_label: &str,
1264 cx: &mut VisualTestContext,
1265 ) {
1266 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1267 tasks_picker
1268 .delegate
1269 .candidates
1270 .iter()
1271 .flatten()
1272 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1273 .cloned()
1274 .unwrap()
1275 });
1276 project.update(cx, |project, cx| {
1277 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1278 task_inventory.update(cx, |inventory, _| {
1279 let (kind, task) = scheduled_task;
1280 inventory.task_scheduled(kind, task);
1281 });
1282 }
1283 });
1284 tasks_picker.update(cx, |_, cx| {
1285 cx.emit(DismissEvent);
1286 });
1287 drop(tasks_picker);
1288 cx.executor().run_until_parked()
1289 }
1290
1291 fn open_spawn_tasks(
1292 workspace: &Entity<Workspace>,
1293 cx: &mut VisualTestContext,
1294 ) -> Entity<Picker<TasksModalDelegate>> {
1295 cx.dispatch_action(Spawn::modal());
1296 workspace.update(cx, |workspace, cx| {
1297 workspace
1298 .active_modal::<TasksModal>(cx)
1299 .expect("no task modal after `Spawn` action was dispatched")
1300 .read(cx)
1301 .picker
1302 .clone()
1303 })
1304 }
1305
1306 fn query(
1307 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1308 cx: &mut VisualTestContext,
1309 ) -> String {
1310 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1311 }
1312
1313 fn task_names(
1314 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1315 cx: &mut VisualTestContext,
1316 ) -> Vec<String> {
1317 spawn_tasks.update(cx, |spawn_tasks, _| {
1318 spawn_tasks
1319 .delegate
1320 .matches
1321 .iter()
1322 .map(|hit| hit.string.clone())
1323 .collect::<Vec<_>>()
1324 })
1325 }
1326}