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