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