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