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