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