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