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