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