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