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, 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 (workspace, cx) =
791 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
792
793 let tasks_picker = open_spawn_tasks(&workspace, cx);
794 assert_eq!(
795 query(&tasks_picker, cx),
796 "",
797 "Initial query should be empty"
798 );
799 assert_eq!(
800 task_names(&tasks_picker, cx),
801 vec!["another one", "example task"],
802 "With no global tasks and no open item, a single worktree should be used and its tasks listed"
803 );
804 drop(tasks_picker);
805
806 let _ = workspace
807 .update_in(cx, |workspace, window, cx| {
808 workspace.open_abs_path(
809 PathBuf::from(path!("/dir/a.ts")),
810 OpenOptions {
811 visible: Some(OpenVisible::All),
812 ..Default::default()
813 },
814 window,
815 cx,
816 )
817 })
818 .await
819 .unwrap();
820 let tasks_picker = open_spawn_tasks(&workspace, cx);
821 assert_eq!(
822 task_names(&tasks_picker, cx),
823 vec!["another one", "example task"],
824 "Initial tasks should be listed in alphabetical order"
825 );
826
827 let query_str = "tas";
828 cx.simulate_input(query_str);
829 assert_eq!(query(&tasks_picker, cx), query_str);
830 assert_eq!(
831 task_names(&tasks_picker, cx),
832 vec!["example task"],
833 "Only one task should match the query {query_str}"
834 );
835
836 cx.dispatch_action(picker::ConfirmCompletion);
837 assert_eq!(
838 query(&tasks_picker, cx),
839 "echo 4",
840 "Query should be set to the selected task's command"
841 );
842 assert_eq!(
843 task_names(&tasks_picker, cx),
844 Vec::<String>::new(),
845 "No task should be listed"
846 );
847 cx.dispatch_action(picker::ConfirmInput { secondary: false });
848
849 let tasks_picker = open_spawn_tasks(&workspace, cx);
850 assert_eq!(
851 query(&tasks_picker, cx),
852 "",
853 "Query should be reset after confirming"
854 );
855 assert_eq!(
856 task_names(&tasks_picker, cx),
857 vec!["echo 4", "another one", "example task"],
858 "New oneshot task should be listed first"
859 );
860
861 let query_str = "echo 4";
862 cx.simulate_input(query_str);
863 assert_eq!(query(&tasks_picker, cx), query_str);
864 assert_eq!(
865 task_names(&tasks_picker, cx),
866 vec!["echo 4"],
867 "New oneshot should match custom command query"
868 );
869
870 cx.dispatch_action(picker::ConfirmInput { secondary: false });
871 let tasks_picker = open_spawn_tasks(&workspace, cx);
872 assert_eq!(
873 query(&tasks_picker, cx),
874 "",
875 "Query should be reset after confirming"
876 );
877 assert_eq!(
878 task_names(&tasks_picker, cx),
879 vec![query_str, "another one", "example task"],
880 "Last recently used one show task should be listed first"
881 );
882
883 cx.dispatch_action(picker::ConfirmCompletion);
884 assert_eq!(
885 query(&tasks_picker, cx),
886 query_str,
887 "Query should be set to the custom task's name"
888 );
889 assert_eq!(
890 task_names(&tasks_picker, cx),
891 vec![query_str],
892 "Only custom task should be listed"
893 );
894
895 let query_str = "0";
896 cx.simulate_input(query_str);
897 assert_eq!(query(&tasks_picker, cx), "echo 40");
898 assert_eq!(
899 task_names(&tasks_picker, cx),
900 Vec::<String>::new(),
901 "New oneshot should not match any command query"
902 );
903
904 cx.dispatch_action(picker::ConfirmInput { secondary: true });
905 let tasks_picker = open_spawn_tasks(&workspace, cx);
906 assert_eq!(
907 query(&tasks_picker, cx),
908 "",
909 "Query should be reset after confirming"
910 );
911 assert_eq!(
912 task_names(&tasks_picker, cx),
913 vec!["echo 4", "another one", "example task"],
914 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
915 );
916
917 cx.dispatch_action(Spawn::ByName {
918 task_name: "example task".to_string(),
919 reveal_target: None,
920 });
921 let tasks_picker = workspace.update(cx, |workspace, cx| {
922 workspace
923 .active_modal::<TasksModal>(cx)
924 .unwrap()
925 .read(cx)
926 .picker
927 .clone()
928 });
929 assert_eq!(
930 task_names(&tasks_picker, cx),
931 vec!["echo 4", "another one", "example task"],
932 );
933 }
934
935 #[gpui::test]
936 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
937 init_test(cx);
938 let fs = FakeFs::new(cx.executor());
939 fs.insert_tree(
940 path!("/dir"),
941 json!({
942 ".zed": {
943 "tasks.json": r#"[
944 {
945 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
946 "command": "echo",
947 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
948 },
949 {
950 "label": "opened now: $ZED_WORKTREE_ROOT",
951 "command": "echo",
952 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
953 }
954 ]"#,
955 },
956 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
957 "file_with.odd_extension": "b",
958 }),
959 )
960 .await;
961
962 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
963 let (workspace, cx) =
964 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
965
966 let tasks_picker = open_spawn_tasks(&workspace, cx);
967 assert_eq!(
968 task_names(&tasks_picker, cx),
969 vec![concat!("opened now: ", path!("/dir")).to_string()],
970 "When no file is open for a single worktree, should autodetect all worktree-related tasks"
971 );
972 tasks_picker.update(cx, |_, cx| {
973 cx.emit(DismissEvent);
974 });
975 drop(tasks_picker);
976 cx.executor().run_until_parked();
977
978 let _ = workspace
979 .update_in(cx, |workspace, window, cx| {
980 workspace.open_abs_path(
981 PathBuf::from(path!("/dir/file_with.odd_extension")),
982 OpenOptions {
983 visible: Some(OpenVisible::All),
984 ..Default::default()
985 },
986 window,
987 cx,
988 )
989 })
990 .await
991 .unwrap();
992 cx.executor().run_until_parked();
993 let tasks_picker = open_spawn_tasks(&workspace, cx);
994 assert_eq!(
995 task_names(&tasks_picker, cx),
996 vec![
997 concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
998 concat!("opened now: ", path!("/dir")).to_string(),
999 ],
1000 "Second opened buffer should fill the context, labels should be trimmed if long enough"
1001 );
1002 tasks_picker.update(cx, |_, cx| {
1003 cx.emit(DismissEvent);
1004 });
1005 drop(tasks_picker);
1006 cx.executor().run_until_parked();
1007
1008 let second_item = workspace
1009 .update_in(cx, |workspace, window, cx| {
1010 workspace.open_abs_path(
1011 PathBuf::from(path!("/dir/file_without_extension")),
1012 OpenOptions {
1013 visible: Some(OpenVisible::All),
1014 ..Default::default()
1015 },
1016 window,
1017 cx,
1018 )
1019 })
1020 .await
1021 .unwrap();
1022
1023 let editor = cx
1024 .update(|_window, cx| second_item.act_as::<Editor>(cx))
1025 .unwrap();
1026 editor.update_in(cx, |editor, window, cx| {
1027 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1028 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1029 })
1030 });
1031 cx.executor().run_until_parked();
1032 let tasks_picker = open_spawn_tasks(&workspace, cx);
1033 assert_eq!(
1034 task_names(&tasks_picker, cx),
1035 vec![
1036 concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1037 concat!("opened now: ", path!("/dir")).to_string(),
1038 ],
1039 "Opened buffer should fill the context, labels should be trimmed if long enough"
1040 );
1041 tasks_picker.update(cx, |_, cx| {
1042 cx.emit(DismissEvent);
1043 });
1044 drop(tasks_picker);
1045 cx.executor().run_until_parked();
1046 }
1047
1048 #[gpui::test]
1049 async fn test_language_task_filtering(cx: &mut TestAppContext) {
1050 init_test(cx);
1051 let fs = FakeFs::new(cx.executor());
1052 fs.insert_tree(
1053 path!("/dir"),
1054 json!({
1055 "a1.ts": "// a1",
1056 "a2.ts": "// a2",
1057 "b.rs": "// b",
1058 }),
1059 )
1060 .await;
1061
1062 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1063 project.read_with(cx, |project, _| {
1064 let language_registry = project.languages();
1065 language_registry.add(Arc::new(
1066 Language::new(
1067 LanguageConfig {
1068 name: "TypeScript".into(),
1069 matcher: LanguageMatcher {
1070 path_suffixes: vec!["ts".to_string()],
1071 ..LanguageMatcher::default()
1072 },
1073 ..LanguageConfig::default()
1074 },
1075 None,
1076 )
1077 .with_context_provider(Some(Arc::new(
1078 ContextProviderWithTasks::new(TaskTemplates(vec![
1079 TaskTemplate {
1080 label: "Task without variables".to_string(),
1081 command: "npm run clean".to_string(),
1082 ..TaskTemplate::default()
1083 },
1084 TaskTemplate {
1085 label: "TypeScript task from file $ZED_FILE".to_string(),
1086 command: "npm run build".to_string(),
1087 ..TaskTemplate::default()
1088 },
1089 TaskTemplate {
1090 label: "Another task from file $ZED_FILE".to_string(),
1091 command: "npm run lint".to_string(),
1092 ..TaskTemplate::default()
1093 },
1094 ])),
1095 ))),
1096 ));
1097 language_registry.add(Arc::new(
1098 Language::new(
1099 LanguageConfig {
1100 name: "Rust".into(),
1101 matcher: LanguageMatcher {
1102 path_suffixes: vec!["rs".to_string()],
1103 ..LanguageMatcher::default()
1104 },
1105 ..LanguageConfig::default()
1106 },
1107 None,
1108 )
1109 .with_context_provider(Some(Arc::new(
1110 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1111 label: "Rust task".to_string(),
1112 command: "cargo check".into(),
1113 ..TaskTemplate::default()
1114 }])),
1115 ))),
1116 ));
1117 });
1118 let (workspace, cx) =
1119 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1120
1121 let _ts_file_1 = workspace
1122 .update_in(cx, |workspace, window, cx| {
1123 workspace.open_abs_path(
1124 PathBuf::from(path!("/dir/a1.ts")),
1125 OpenOptions {
1126 visible: Some(OpenVisible::All),
1127 ..Default::default()
1128 },
1129 window,
1130 cx,
1131 )
1132 })
1133 .await
1134 .unwrap();
1135 let tasks_picker = open_spawn_tasks(&workspace, cx);
1136 assert_eq!(
1137 task_names(&tasks_picker, cx),
1138 vec![
1139 concat!("Another task from file ", path!("/dir/a1.ts")),
1140 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1141 "Task without variables",
1142 ],
1143 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1144 );
1145
1146 emulate_task_schedule(
1147 tasks_picker,
1148 &project,
1149 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1150 cx,
1151 );
1152
1153 let tasks_picker = open_spawn_tasks(&workspace, cx);
1154 assert_eq!(
1155 task_names(&tasks_picker, cx),
1156 vec![
1157 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1158 concat!("Another task from file ", path!("/dir/a1.ts")),
1159 "Task without variables",
1160 ],
1161 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1162 Tasks with the same labels and context are deduplicated."
1163 );
1164 tasks_picker.update(cx, |_, cx| {
1165 cx.emit(DismissEvent);
1166 });
1167 drop(tasks_picker);
1168 cx.executor().run_until_parked();
1169
1170 let _ts_file_2 = workspace
1171 .update_in(cx, |workspace, window, cx| {
1172 workspace.open_abs_path(
1173 PathBuf::from(path!("/dir/a2.ts")),
1174 OpenOptions {
1175 visible: Some(OpenVisible::All),
1176 ..Default::default()
1177 },
1178 window,
1179 cx,
1180 )
1181 })
1182 .await
1183 .unwrap();
1184 let tasks_picker = open_spawn_tasks(&workspace, cx);
1185 assert_eq!(
1186 task_names(&tasks_picker, cx),
1187 vec![
1188 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1189 concat!("Another task from file ", path!("/dir/a2.ts")),
1190 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1191 "Task without variables",
1192 ],
1193 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1194 );
1195 tasks_picker.update(cx, |_, cx| {
1196 cx.emit(DismissEvent);
1197 });
1198 drop(tasks_picker);
1199 cx.executor().run_until_parked();
1200
1201 let _rs_file = workspace
1202 .update_in(cx, |workspace, window, cx| {
1203 workspace.open_abs_path(
1204 PathBuf::from(path!("/dir/b.rs")),
1205 OpenOptions {
1206 visible: Some(OpenVisible::All),
1207 ..Default::default()
1208 },
1209 window,
1210 cx,
1211 )
1212 })
1213 .await
1214 .unwrap();
1215 let tasks_picker = open_spawn_tasks(&workspace, cx);
1216 assert_eq!(
1217 task_names(&tasks_picker, cx),
1218 vec!["Rust task"],
1219 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1220 );
1221
1222 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1223 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1224 let _ts_file_2 = workspace
1225 .update_in(cx, |workspace, window, cx| {
1226 workspace.open_abs_path(
1227 PathBuf::from(path!("/dir/a2.ts")),
1228 OpenOptions {
1229 visible: Some(OpenVisible::All),
1230 ..Default::default()
1231 },
1232 window,
1233 cx,
1234 )
1235 })
1236 .await
1237 .unwrap();
1238 let tasks_picker = open_spawn_tasks(&workspace, cx);
1239 assert_eq!(
1240 task_names(&tasks_picker, cx),
1241 vec![
1242 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1243 concat!("Another task from file ", path!("/dir/a2.ts")),
1244 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1245 "Task without variables",
1246 ],
1247 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1248 same TS spawn history should be restored"
1249 );
1250 }
1251
1252 fn emulate_task_schedule(
1253 tasks_picker: Entity<Picker<TasksModalDelegate>>,
1254 project: &Entity<Project>,
1255 scheduled_task_label: &str,
1256 cx: &mut VisualTestContext,
1257 ) {
1258 let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1259 tasks_picker
1260 .delegate
1261 .candidates
1262 .iter()
1263 .flatten()
1264 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1265 .cloned()
1266 .unwrap()
1267 });
1268 project.update(cx, |project, cx| {
1269 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1270 task_inventory.update(cx, |inventory, _| {
1271 let (kind, task) = scheduled_task;
1272 inventory.task_scheduled(kind, task);
1273 });
1274 }
1275 });
1276 tasks_picker.update(cx, |_, cx| {
1277 cx.emit(DismissEvent);
1278 });
1279 drop(tasks_picker);
1280 cx.executor().run_until_parked()
1281 }
1282
1283 fn open_spawn_tasks(
1284 workspace: &Entity<Workspace>,
1285 cx: &mut VisualTestContext,
1286 ) -> Entity<Picker<TasksModalDelegate>> {
1287 cx.dispatch_action(Spawn::modal());
1288 workspace.update(cx, |workspace, cx| {
1289 workspace
1290 .active_modal::<TasksModal>(cx)
1291 .expect("no task modal after `Spawn` action was dispatched")
1292 .read(cx)
1293 .picker
1294 .clone()
1295 })
1296 }
1297
1298 fn query(
1299 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1300 cx: &mut VisualTestContext,
1301 ) -> String {
1302 spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1303 }
1304
1305 fn task_names(
1306 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1307 cx: &mut VisualTestContext,
1308 ) -> Vec<String> {
1309 spawn_tasks.read_with(cx, |spawn_tasks, _| {
1310 spawn_tasks
1311 .delegate
1312 .matches
1313 .iter()
1314 .map(|hit| hit.string.clone())
1315 .collect::<Vec<_>>()
1316 })
1317 }
1318}