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.ends_with(".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.ends_with(".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[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 char_count: hit.string.chars().count(),
486 color: Color::Default,
487 };
488 let icon = match source_kind {
489 TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
490 TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
491 TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
492 TaskSourceKind::Lsp {
493 language_name: name,
494 ..
495 }
496 | TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
497 .get_icon_for_type(&name.to_lowercase(), cx)
498 .map(Icon::from_path),
499 }
500 .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
501 let indicator = if matches!(source_kind, TaskSourceKind::Lsp { .. }) {
502 Some(Indicator::icon(
503 Icon::new(IconName::BoltOutlined).size(IconSize::Small),
504 ))
505 } else {
506 None
507 };
508 let icon = icon.map(|icon| {
509 IconWithIndicator::new(icon, indicator)
510 .indicator_border_color(Some(cx.theme().colors().border_transparent))
511 });
512 let history_run_icon = if Some(ix) <= self.divider_index {
513 Some(
514 Icon::new(IconName::HistoryRerun)
515 .color(Color::Muted)
516 .size(IconSize::Small)
517 .into_any_element(),
518 )
519 } else {
520 Some(
521 v_flex()
522 .flex_none()
523 .size(IconSize::Small.rems())
524 .into_any_element(),
525 )
526 };
527
528 Some(
529 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
530 .inset(true)
531 .start_slot::<IconWithIndicator>(icon)
532 .end_slot::<AnyElement>(
533 h_flex()
534 .gap_1()
535 .child(Label::new(truncate_and_trailoff(
536 &template
537 .tags
538 .iter()
539 .map(|tag| format!("#{}", tag))
540 .collect::<Vec<_>>()
541 .join(" "),
542 MAX_TAGS_LINE_LEN,
543 )))
544 .flex_none()
545 .child(history_run_icon.unwrap())
546 .into_any_element(),
547 )
548 .spacing(ListItemSpacing::Sparse)
549 .when_some(tooltip_label, |list_item, item_label| {
550 list_item.tooltip(move |_, _| item_label.clone())
551 })
552 .map(|item| {
553 if matches!(source_kind, TaskSourceKind::UserInput)
554 || Some(ix) <= self.divider_index
555 {
556 let task_index = hit.candidate_id;
557 let delete_button = div().child(
558 IconButton::new("delete", IconName::Close)
559 .shape(IconButtonShape::Square)
560 .icon_color(Color::Muted)
561 .size(ButtonSize::None)
562 .icon_size(IconSize::XSmall)
563 .on_click(cx.listener(move |picker, _event, window, cx| {
564 cx.stop_propagation();
565 window.prevent_default();
566
567 picker.delegate.delete_previously_used(task_index, cx);
568 picker.delegate.last_used_candidate_index = picker
569 .delegate
570 .last_used_candidate_index
571 .unwrap_or(0)
572 .checked_sub(1);
573 picker.refresh(window, cx);
574 }))
575 .tooltip(|_, cx| {
576 Tooltip::simple("Delete Previously Scheduled Task", cx)
577 }),
578 );
579 item.end_hover_slot(delete_button)
580 } else {
581 item
582 }
583 })
584 .toggle_state(selected)
585 .child(highlighted_location.render(window, cx)),
586 )
587 }
588
589 fn confirm_completion(
590 &mut self,
591 _: String,
592 _window: &mut Window,
593 _: &mut Context<Picker<Self>>,
594 ) -> Option<String> {
595 let task_index = self.matches.get(self.selected_index())?.candidate_id;
596 let tasks = self.candidates.as_ref()?;
597 let (_, task) = tasks.get(task_index)?;
598 Some(task.resolved.command_label.clone())
599 }
600
601 fn confirm_input(
602 &mut self,
603 omit_history_entry: bool,
604 window: &mut Window,
605 cx: &mut Context<Picker<Self>>,
606 ) {
607 let Some((task_source_kind, mut task)) = self.spawn_oneshot() else {
608 return;
609 };
610
611 if let Some(TaskOverrides {
612 reveal_target: Some(reveal_target),
613 }) = self.task_overrides
614 {
615 task.resolved.reveal_target = reveal_target;
616 }
617 self.workspace
618 .update(cx, |workspace, cx| {
619 workspace.schedule_resolved_task(
620 task_source_kind,
621 task,
622 omit_history_entry,
623 window,
624 cx,
625 )
626 })
627 .ok();
628 cx.emit(DismissEvent);
629 }
630
631 fn separators_after_indices(&self) -> Vec<usize> {
632 if let Some(i) = self.divider_index {
633 vec![i]
634 } else {
635 Vec::new()
636 }
637 }
638
639 fn render_footer(
640 &self,
641 window: &mut Window,
642 cx: &mut Context<Picker<Self>>,
643 ) -> Option<gpui::AnyElement> {
644 let is_recent_selected = self.divider_index >= Some(self.selected_index);
645 let current_modifiers = window.modifiers();
646 let left_button = if self
647 .task_store
648 .read(cx)
649 .task_inventory()?
650 .read(cx)
651 .last_scheduled_task(None)
652 .is_some()
653 {
654 Some(("Rerun Last Task", Rerun::default().boxed_clone()))
655 } else {
656 None
657 };
658 Some(
659 h_flex()
660 .w_full()
661 .p_1p5()
662 .justify_between()
663 .border_t_1()
664 .border_color(cx.theme().colors().border_variant)
665 .child(
666 left_button
667 .map(|(label, action)| {
668 let keybind = KeyBinding::for_action(&*action, window, cx);
669
670 Button::new("edit-current-task", label)
671 .when_some(keybind, |this, keybind| this.key_binding(keybind))
672 .on_click(move |_, window, cx| {
673 window.dispatch_action(action.boxed_clone(), cx);
674 })
675 .into_any_element()
676 })
677 .unwrap_or_else(|| h_flex().into_any_element()),
678 )
679 .map(|this| {
680 if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty()
681 {
682 let action = picker::ConfirmInput {
683 secondary: current_modifiers.secondary(),
684 }
685 .boxed_clone();
686 this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
687 let spawn_oneshot_label = if current_modifiers.secondary() {
688 "Spawn Oneshot Without History"
689 } else {
690 "Spawn Oneshot"
691 };
692
693 Button::new("spawn-onehshot", spawn_oneshot_label)
694 .key_binding(keybind)
695 .on_click(move |_, window, cx| {
696 window.dispatch_action(action.boxed_clone(), cx)
697 })
698 }))
699 } else if current_modifiers.secondary() {
700 this.children(
701 KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
702 |keybind| {
703 let label = if is_recent_selected {
704 "Rerun Without History"
705 } else {
706 "Spawn Without History"
707 };
708 Button::new("spawn", label).key_binding(keybind).on_click(
709 move |_, window, cx| {
710 window.dispatch_action(
711 menu::SecondaryConfirm.boxed_clone(),
712 cx,
713 )
714 },
715 )
716 },
717 ),
718 )
719 } else {
720 this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
721 |keybind| {
722 let run_entry_label =
723 if is_recent_selected { "Rerun" } else { "Spawn" };
724
725 Button::new("spawn", run_entry_label)
726 .key_binding(keybind)
727 .on_click(|_, window, cx| {
728 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
729 })
730 },
731 ))
732 }
733 })
734 .into_any_element(),
735 )
736 }
737}
738
739fn string_match_candidates<'a>(
740 candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
741) -> Vec<StringMatchCandidate> {
742 candidates
743 .into_iter()
744 .enumerate()
745 .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
746 .collect()
747}
748
749#[cfg(test)]
750mod tests {
751 use std::{path::PathBuf, sync::Arc};
752
753 use editor::{Editor, SelectionEffects};
754 use gpui::{TestAppContext, VisualTestContext};
755 use language::{Language, LanguageConfig, LanguageMatcher, Point};
756 use project::{ContextProviderWithTasks, FakeFs, Project};
757 use serde_json::json;
758 use task::TaskTemplates;
759 use util::path;
760 use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
761
762 use crate::{modal::Spawn, tests::init_test};
763
764 use super::*;
765
766 #[gpui::test]
767 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
768 init_test(cx);
769 let fs = FakeFs::new(cx.executor());
770 fs.insert_tree(
771 path!("/dir"),
772 json!({
773 ".zed": {
774 "tasks.json": r#"[
775 {
776 "label": "example task",
777 "command": "echo",
778 "args": ["4"]
779 },
780 {
781 "label": "another one",
782 "command": "echo",
783 "args": ["55"]
784 },
785 ]"#,
786 },
787 "a.ts": "a"
788 }),
789 )
790 .await;
791
792 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
793 let (workspace, cx) =
794 cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
795
796 let tasks_picker = open_spawn_tasks(&workspace, cx);
797 assert_eq!(
798 query(&tasks_picker, cx),
799 "",
800 "Initial query should be empty"
801 );
802 assert_eq!(
803 task_names(&tasks_picker, cx),
804 vec!["another one", "example task"],
805 "With no global tasks and no open item, a single worktree should be used and its tasks listed"
806 );
807 drop(tasks_picker);
808
809 let _ = workspace
810 .update_in(cx, |workspace, window, cx| {
811 workspace.open_abs_path(
812 PathBuf::from(path!("/dir/a.ts")),
813 OpenOptions {
814 visible: Some(OpenVisible::All),
815 ..Default::default()
816 },
817 window,
818 cx,
819 )
820 })
821 .await
822 .unwrap();
823 let tasks_picker = open_spawn_tasks(&workspace, cx);
824 assert_eq!(
825 task_names(&tasks_picker, cx),
826 vec!["another one", "example task"],
827 "Initial tasks should be listed in alphabetical order"
828 );
829
830 let query_str = "tas";
831 cx.simulate_input(query_str);
832 assert_eq!(query(&tasks_picker, cx), query_str);
833 assert_eq!(
834 task_names(&tasks_picker, cx),
835 vec!["example task"],
836 "Only one task should match the query {query_str}"
837 );
838
839 cx.dispatch_action(picker::ConfirmCompletion);
840 assert_eq!(
841 query(&tasks_picker, cx),
842 "echo 4",
843 "Query should be set to the selected task's command"
844 );
845 assert_eq!(
846 task_names(&tasks_picker, cx),
847 Vec::<String>::new(),
848 "No task should be listed"
849 );
850 cx.dispatch_action(picker::ConfirmInput { secondary: false });
851
852 let tasks_picker = open_spawn_tasks(&workspace, cx);
853 assert_eq!(
854 query(&tasks_picker, cx),
855 "",
856 "Query should be reset after confirming"
857 );
858 assert_eq!(
859 task_names(&tasks_picker, cx),
860 vec!["echo 4", "another one", "example task"],
861 "New oneshot task should be listed first"
862 );
863
864 let query_str = "echo 4";
865 cx.simulate_input(query_str);
866 assert_eq!(query(&tasks_picker, cx), query_str);
867 assert_eq!(
868 task_names(&tasks_picker, cx),
869 vec!["echo 4"],
870 "New oneshot should match custom command query"
871 );
872
873 cx.dispatch_action(picker::ConfirmInput { secondary: false });
874 let tasks_picker = open_spawn_tasks(&workspace, cx);
875 assert_eq!(
876 query(&tasks_picker, cx),
877 "",
878 "Query should be reset after confirming"
879 );
880 assert_eq!(
881 task_names(&tasks_picker, cx),
882 vec![query_str, "another one", "example task"],
883 "Last recently used one show task should be listed first"
884 );
885
886 cx.dispatch_action(picker::ConfirmCompletion);
887 assert_eq!(
888 query(&tasks_picker, cx),
889 query_str,
890 "Query should be set to the custom task's name"
891 );
892 assert_eq!(
893 task_names(&tasks_picker, cx),
894 vec![query_str],
895 "Only custom task should be listed"
896 );
897
898 let query_str = "0";
899 cx.simulate_input(query_str);
900 assert_eq!(query(&tasks_picker, cx), "echo 40");
901 assert_eq!(
902 task_names(&tasks_picker, cx),
903 Vec::<String>::new(),
904 "New oneshot should not match any command query"
905 );
906
907 cx.dispatch_action(picker::ConfirmInput { secondary: true });
908 let tasks_picker = open_spawn_tasks(&workspace, cx);
909 assert_eq!(
910 query(&tasks_picker, cx),
911 "",
912 "Query should be reset after confirming"
913 );
914 assert_eq!(
915 task_names(&tasks_picker, cx),
916 vec!["echo 4", "another one", "example task"],
917 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
918 );
919
920 cx.dispatch_action(Spawn::ByName {
921 task_name: "example task".to_string(),
922 reveal_target: None,
923 });
924 let tasks_picker = workspace.update(cx, |workspace, cx| {
925 workspace
926 .active_modal::<TasksModal>(cx)
927 .unwrap()
928 .read(cx)
929 .picker
930 .clone()
931 });
932 assert_eq!(
933 task_names(&tasks_picker, cx),
934 vec!["echo 4", "another one", "example task"],
935 );
936 }
937
938 #[gpui::test]
939 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
940 init_test(cx);
941 let fs = FakeFs::new(cx.executor());
942 fs.insert_tree(
943 path!("/dir"),
944 json!({
945 ".zed": {
946 "tasks.json": r#"[
947 {
948 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
949 "command": "echo",
950 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
951 },
952 {
953 "label": "opened now: $ZED_WORKTREE_ROOT",
954 "command": "echo",
955 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
956 }
957 ]"#,
958 },
959 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
960 "file_with.odd_extension": "b",
961 }),
962 )
963 .await;
964
965 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
966 let (workspace, cx) =
967 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
968
969 let tasks_picker = open_spawn_tasks(&workspace, cx);
970 assert_eq!(
971 task_names(&tasks_picker, cx),
972 vec![concat!("opened now: ", path!("/dir")).to_string()],
973 "When no file is open for a single worktree, should autodetect all worktree-related tasks"
974 );
975 tasks_picker.update(cx, |_, cx| {
976 cx.emit(DismissEvent);
977 });
978 drop(tasks_picker);
979 cx.executor().run_until_parked();
980
981 let _ = workspace
982 .update_in(cx, |workspace, window, cx| {
983 workspace.open_abs_path(
984 PathBuf::from(path!("/dir/file_with.odd_extension")),
985 OpenOptions {
986 visible: Some(OpenVisible::All),
987 ..Default::default()
988 },
989 window,
990 cx,
991 )
992 })
993 .await
994 .unwrap();
995 cx.executor().run_until_parked();
996 let tasks_picker = open_spawn_tasks(&workspace, cx);
997 assert_eq!(
998 task_names(&tasks_picker, cx),
999 vec![
1000 concat!("hello from ", path!("/dir/file_with.odd_extension:1:1")).to_string(),
1001 concat!("opened now: ", path!("/dir")).to_string(),
1002 ],
1003 "Second opened buffer should fill the context, labels should be trimmed if long enough"
1004 );
1005 tasks_picker.update(cx, |_, cx| {
1006 cx.emit(DismissEvent);
1007 });
1008 drop(tasks_picker);
1009 cx.executor().run_until_parked();
1010
1011 let second_item = workspace
1012 .update_in(cx, |workspace, window, cx| {
1013 workspace.open_abs_path(
1014 PathBuf::from(path!("/dir/file_without_extension")),
1015 OpenOptions {
1016 visible: Some(OpenVisible::All),
1017 ..Default::default()
1018 },
1019 window,
1020 cx,
1021 )
1022 })
1023 .await
1024 .unwrap();
1025
1026 let editor = cx
1027 .update(|_window, cx| second_item.act_as::<Editor>(cx))
1028 .unwrap();
1029 editor.update_in(cx, |editor, window, cx| {
1030 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1031 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
1032 })
1033 });
1034 cx.executor().run_until_parked();
1035 let tasks_picker = open_spawn_tasks(&workspace, cx);
1036 assert_eq!(
1037 task_names(&tasks_picker, cx),
1038 vec![
1039 concat!("hello from ", path!("/dir/file_without_extension:2:3")).to_string(),
1040 concat!("opened now: ", path!("/dir")).to_string(),
1041 ],
1042 "Opened buffer should fill the context, labels should be trimmed if long enough"
1043 );
1044 tasks_picker.update(cx, |_, cx| {
1045 cx.emit(DismissEvent);
1046 });
1047 drop(tasks_picker);
1048 cx.executor().run_until_parked();
1049 }
1050
1051 #[gpui::test]
1052 async fn test_language_task_filtering(cx: &mut TestAppContext) {
1053 init_test(cx);
1054 let fs = FakeFs::new(cx.executor());
1055 fs.insert_tree(
1056 path!("/dir"),
1057 json!({
1058 "a1.ts": "// a1",
1059 "a2.ts": "// a2",
1060 "b.rs": "// b",
1061 }),
1062 )
1063 .await;
1064
1065 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1066 project.read_with(cx, |project, _| {
1067 let language_registry = project.languages();
1068 language_registry.add(Arc::new(
1069 Language::new(
1070 LanguageConfig {
1071 name: "TypeScript".into(),
1072 matcher: LanguageMatcher {
1073 path_suffixes: vec!["ts".to_string()],
1074 ..LanguageMatcher::default()
1075 },
1076 ..LanguageConfig::default()
1077 },
1078 None,
1079 )
1080 .with_context_provider(Some(Arc::new(
1081 ContextProviderWithTasks::new(TaskTemplates(vec![
1082 TaskTemplate {
1083 label: "Task without variables".to_string(),
1084 command: "npm run clean".to_string(),
1085 ..TaskTemplate::default()
1086 },
1087 TaskTemplate {
1088 label: "TypeScript task from file $ZED_FILE".to_string(),
1089 command: "npm run build".to_string(),
1090 ..TaskTemplate::default()
1091 },
1092 TaskTemplate {
1093 label: "Another task from file $ZED_FILE".to_string(),
1094 command: "npm run lint".to_string(),
1095 ..TaskTemplate::default()
1096 },
1097 ])),
1098 ))),
1099 ));
1100 language_registry.add(Arc::new(
1101 Language::new(
1102 LanguageConfig {
1103 name: "Rust".into(),
1104 matcher: LanguageMatcher {
1105 path_suffixes: vec!["rs".to_string()],
1106 ..LanguageMatcher::default()
1107 },
1108 ..LanguageConfig::default()
1109 },
1110 None,
1111 )
1112 .with_context_provider(Some(Arc::new(
1113 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
1114 label: "Rust task".to_string(),
1115 command: "cargo check".into(),
1116 ..TaskTemplate::default()
1117 }])),
1118 ))),
1119 ));
1120 });
1121 let (workspace, cx) =
1122 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1123
1124 let _ts_file_1 = workspace
1125 .update_in(cx, |workspace, window, cx| {
1126 workspace.open_abs_path(
1127 PathBuf::from(path!("/dir/a1.ts")),
1128 OpenOptions {
1129 visible: Some(OpenVisible::All),
1130 ..Default::default()
1131 },
1132 window,
1133 cx,
1134 )
1135 })
1136 .await
1137 .unwrap();
1138 let tasks_picker = open_spawn_tasks(&workspace, cx);
1139 assert_eq!(
1140 task_names(&tasks_picker, cx),
1141 vec![
1142 concat!("Another task from file ", path!("/dir/a1.ts")),
1143 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1144 "Task without variables",
1145 ],
1146 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
1147 );
1148
1149 emulate_task_schedule(
1150 tasks_picker,
1151 &project,
1152 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1153 cx,
1154 );
1155
1156 let tasks_picker = open_spawn_tasks(&workspace, cx);
1157 assert_eq!(
1158 task_names(&tasks_picker, cx),
1159 vec![
1160 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1161 concat!("Another task from file ", path!("/dir/a1.ts")),
1162 "Task without variables",
1163 ],
1164 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
1165 Tasks with the same labels and context are deduplicated."
1166 );
1167 tasks_picker.update(cx, |_, cx| {
1168 cx.emit(DismissEvent);
1169 });
1170 drop(tasks_picker);
1171 cx.executor().run_until_parked();
1172
1173 let _ts_file_2 = workspace
1174 .update_in(cx, |workspace, window, cx| {
1175 workspace.open_abs_path(
1176 PathBuf::from(path!("/dir/a2.ts")),
1177 OpenOptions {
1178 visible: Some(OpenVisible::All),
1179 ..Default::default()
1180 },
1181 window,
1182 cx,
1183 )
1184 })
1185 .await
1186 .unwrap();
1187 let tasks_picker = open_spawn_tasks(&workspace, cx);
1188 assert_eq!(
1189 task_names(&tasks_picker, cx),
1190 vec![
1191 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1192 concat!("Another task from file ", path!("/dir/a2.ts")),
1193 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1194 "Task without variables",
1195 ],
1196 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
1197 );
1198 tasks_picker.update(cx, |_, cx| {
1199 cx.emit(DismissEvent);
1200 });
1201 drop(tasks_picker);
1202 cx.executor().run_until_parked();
1203
1204 let _rs_file = workspace
1205 .update_in(cx, |workspace, window, cx| {
1206 workspace.open_abs_path(
1207 PathBuf::from(path!("/dir/b.rs")),
1208 OpenOptions {
1209 visible: Some(OpenVisible::All),
1210 ..Default::default()
1211 },
1212 window,
1213 cx,
1214 )
1215 })
1216 .await
1217 .unwrap();
1218 let tasks_picker = open_spawn_tasks(&workspace, cx);
1219 assert_eq!(
1220 task_names(&tasks_picker, cx),
1221 vec!["Rust task"],
1222 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
1223 );
1224
1225 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
1226 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
1227 let _ts_file_2 = workspace
1228 .update_in(cx, |workspace, window, cx| {
1229 workspace.open_abs_path(
1230 PathBuf::from(path!("/dir/a2.ts")),
1231 OpenOptions {
1232 visible: Some(OpenVisible::All),
1233 ..Default::default()
1234 },
1235 window,
1236 cx,
1237 )
1238 })
1239 .await
1240 .unwrap();
1241 let tasks_picker = open_spawn_tasks(&workspace, cx);
1242 assert_eq!(
1243 task_names(&tasks_picker, cx),
1244 vec![
1245 concat!("TypeScript task from file ", path!("/dir/a1.ts")),
1246 concat!("Another task from file ", path!("/dir/a2.ts")),
1247 concat!("TypeScript task from file ", path!("/dir/a2.ts")),
1248 "Task without variables",
1249 ],
1250 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1251 same TS spawn history should be restored"
1252 );
1253 }
1254
1255 fn emulate_task_schedule(
1256 tasks_picker: Entity<Picker<TasksModalDelegate>>,
1257 project: &Entity<Project>,
1258 scheduled_task_label: &str,
1259 cx: &mut VisualTestContext,
1260 ) {
1261 let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
1262 tasks_picker
1263 .delegate
1264 .candidates
1265 .iter()
1266 .flatten()
1267 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1268 .cloned()
1269 .unwrap()
1270 });
1271 project.update(cx, |project, cx| {
1272 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1273 task_inventory.update(cx, |inventory, _| {
1274 let (kind, task) = scheduled_task;
1275 inventory.task_scheduled(kind, task);
1276 });
1277 }
1278 });
1279 tasks_picker.update(cx, |_, cx| {
1280 cx.emit(DismissEvent);
1281 });
1282 drop(tasks_picker);
1283 cx.executor().run_until_parked()
1284 }
1285
1286 fn open_spawn_tasks(
1287 workspace: &Entity<Workspace>,
1288 cx: &mut VisualTestContext,
1289 ) -> Entity<Picker<TasksModalDelegate>> {
1290 cx.dispatch_action(Spawn::modal());
1291 workspace.update(cx, |workspace, cx| {
1292 workspace
1293 .active_modal::<TasksModal>(cx)
1294 .expect("no task modal after `Spawn` action was dispatched")
1295 .read(cx)
1296 .picker
1297 .clone()
1298 })
1299 }
1300
1301 fn query(
1302 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1303 cx: &mut VisualTestContext,
1304 ) -> String {
1305 spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1306 }
1307
1308 fn task_names(
1309 spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
1310 cx: &mut VisualTestContext,
1311 ) -> Vec<String> {
1312 spawn_tasks.read_with(cx, |spawn_tasks, _| {
1313 spawn_tasks
1314 .delegate
1315 .matches
1316 .iter()
1317 .map(|hit| hit.string.clone())
1318 .collect::<Vec<_>>()
1319 })
1320 }
1321}