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