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