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