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 {
559 id: index,
560 char_bag: candidate.resolved_label.chars().collect(),
561 string: candidate.display_label().into(),
562 })
563 .collect()
564}
565
566#[cfg(test)]
567mod tests {
568 use std::{path::PathBuf, sync::Arc};
569
570 use editor::Editor;
571 use gpui::{TestAppContext, VisualTestContext};
572 use language::{Language, LanguageConfig, LanguageMatcher, Point};
573 use project::{ContextProviderWithTasks, FakeFs, Project};
574 use serde_json::json;
575 use task::TaskTemplates;
576 use workspace::CloseInactiveTabsAndPanes;
577
578 use crate::{modal::Spawn, tests::init_test};
579
580 use super::*;
581
582 #[gpui::test]
583 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
584 init_test(cx);
585 let fs = FakeFs::new(cx.executor());
586 fs.insert_tree(
587 "/dir",
588 json!({
589 ".zed": {
590 "tasks.json": r#"[
591 {
592 "label": "example task",
593 "command": "echo",
594 "args": ["4"]
595 },
596 {
597 "label": "another one",
598 "command": "echo",
599 "args": ["55"]
600 },
601 ]"#,
602 },
603 "a.ts": "a"
604 }),
605 )
606 .await;
607
608 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
609 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
610
611 let tasks_picker = open_spawn_tasks(&workspace, cx);
612 assert_eq!(
613 query(&tasks_picker, cx),
614 "",
615 "Initial query should be empty"
616 );
617 assert_eq!(
618 task_names(&tasks_picker, cx),
619 Vec::<String>::new(),
620 "With no global tasks and no open item, no tasks should be listed"
621 );
622 drop(tasks_picker);
623
624 let _ = workspace
625 .update(cx, |workspace, cx| {
626 workspace.open_abs_path(PathBuf::from("/dir/a.ts"), true, cx)
627 })
628 .await
629 .unwrap();
630 let tasks_picker = open_spawn_tasks(&workspace, cx);
631 assert_eq!(
632 task_names(&tasks_picker, cx),
633 vec!["another one", "example task"],
634 "Initial tasks should be listed in alphabetical order"
635 );
636
637 let query_str = "tas";
638 cx.simulate_input(query_str);
639 assert_eq!(query(&tasks_picker, cx), query_str);
640 assert_eq!(
641 task_names(&tasks_picker, cx),
642 vec!["example task"],
643 "Only one task should match the query {query_str}"
644 );
645
646 cx.dispatch_action(picker::ConfirmCompletion);
647 assert_eq!(
648 query(&tasks_picker, cx),
649 "echo 4",
650 "Query should be set to the selected task's command"
651 );
652 assert_eq!(
653 task_names(&tasks_picker, cx),
654 Vec::<String>::new(),
655 "No task should be listed"
656 );
657 cx.dispatch_action(picker::ConfirmInput { secondary: false });
658
659 let tasks_picker = open_spawn_tasks(&workspace, cx);
660 assert_eq!(
661 query(&tasks_picker, cx),
662 "",
663 "Query should be reset after confirming"
664 );
665 assert_eq!(
666 task_names(&tasks_picker, cx),
667 vec!["echo 4", "another one", "example task"],
668 "New oneshot task should be listed first"
669 );
670
671 let query_str = "echo 4";
672 cx.simulate_input(query_str);
673 assert_eq!(query(&tasks_picker, cx), query_str);
674 assert_eq!(
675 task_names(&tasks_picker, cx),
676 vec!["echo 4"],
677 "New oneshot should match custom command query"
678 );
679
680 cx.dispatch_action(picker::ConfirmInput { secondary: false });
681 let tasks_picker = open_spawn_tasks(&workspace, cx);
682 assert_eq!(
683 query(&tasks_picker, cx),
684 "",
685 "Query should be reset after confirming"
686 );
687 assert_eq!(
688 task_names(&tasks_picker, cx),
689 vec![query_str, "another one", "example task"],
690 "Last recently used one show task should be listed first"
691 );
692
693 cx.dispatch_action(picker::ConfirmCompletion);
694 assert_eq!(
695 query(&tasks_picker, cx),
696 query_str,
697 "Query should be set to the custom task's name"
698 );
699 assert_eq!(
700 task_names(&tasks_picker, cx),
701 vec![query_str],
702 "Only custom task should be listed"
703 );
704
705 let query_str = "0";
706 cx.simulate_input(query_str);
707 assert_eq!(query(&tasks_picker, cx), "echo 40");
708 assert_eq!(
709 task_names(&tasks_picker, cx),
710 Vec::<String>::new(),
711 "New oneshot should not match any command query"
712 );
713
714 cx.dispatch_action(picker::ConfirmInput { secondary: true });
715 let tasks_picker = open_spawn_tasks(&workspace, cx);
716 assert_eq!(
717 query(&tasks_picker, cx),
718 "",
719 "Query should be reset after confirming"
720 );
721 assert_eq!(
722 task_names(&tasks_picker, cx),
723 vec!["echo 4", "another one", "example task"],
724 "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
725 );
726
727 cx.dispatch_action(Spawn::ByName {
728 task_name: "example task".to_string(),
729 reveal_target: None,
730 });
731 let tasks_picker = workspace.update(cx, |workspace, cx| {
732 workspace
733 .active_modal::<TasksModal>(cx)
734 .unwrap()
735 .read(cx)
736 .picker
737 .clone()
738 });
739 assert_eq!(
740 task_names(&tasks_picker, cx),
741 vec!["echo 4", "another one", "example task"],
742 );
743 }
744
745 #[gpui::test]
746 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
747 init_test(cx);
748 let fs = FakeFs::new(cx.executor());
749 fs.insert_tree(
750 "/dir",
751 json!({
752 ".zed": {
753 "tasks.json": r#"[
754 {
755 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
756 "command": "echo",
757 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
758 },
759 {
760 "label": "opened now: $ZED_WORKTREE_ROOT",
761 "command": "echo",
762 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
763 }
764 ]"#,
765 },
766 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
767 "file_with.odd_extension": "b",
768 }),
769 )
770 .await;
771
772 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
773 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
774
775 let tasks_picker = open_spawn_tasks(&workspace, cx);
776 assert_eq!(
777 task_names(&tasks_picker, cx),
778 Vec::<String>::new(),
779 "Should list no file or worktree context-dependent when no file is open"
780 );
781 tasks_picker.update(cx, |_, cx| {
782 cx.emit(DismissEvent);
783 });
784 drop(tasks_picker);
785 cx.executor().run_until_parked();
786
787 let _ = workspace
788 .update(cx, |workspace, cx| {
789 workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
790 })
791 .await
792 .unwrap();
793 cx.executor().run_until_parked();
794 let tasks_picker = open_spawn_tasks(&workspace, cx);
795 assert_eq!(
796 task_names(&tasks_picker, cx),
797 vec![
798 "hello from …th.odd_extension:1:1".to_string(),
799 "opened now: /dir".to_string()
800 ],
801 "Second opened buffer should fill the context, labels should be trimmed if long enough"
802 );
803 tasks_picker.update(cx, |_, cx| {
804 cx.emit(DismissEvent);
805 });
806 drop(tasks_picker);
807 cx.executor().run_until_parked();
808
809 let second_item = workspace
810 .update(cx, |workspace, cx| {
811 workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
812 })
813 .await
814 .unwrap();
815
816 let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
817 editor.update(cx, |editor, cx| {
818 editor.change_selections(None, cx, |s| {
819 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
820 })
821 });
822 cx.executor().run_until_parked();
823 let tasks_picker = open_spawn_tasks(&workspace, cx);
824 assert_eq!(
825 task_names(&tasks_picker, cx),
826 vec![
827 "hello from …ithout_extension:2:3".to_string(),
828 "opened now: /dir".to_string()
829 ],
830 "Opened buffer should fill the context, labels should be trimmed if long enough"
831 );
832 tasks_picker.update(cx, |_, cx| {
833 cx.emit(DismissEvent);
834 });
835 drop(tasks_picker);
836 cx.executor().run_until_parked();
837 }
838
839 #[gpui::test]
840 async fn test_language_task_filtering(cx: &mut TestAppContext) {
841 init_test(cx);
842 let fs = FakeFs::new(cx.executor());
843 fs.insert_tree(
844 "/dir",
845 json!({
846 "a1.ts": "// a1",
847 "a2.ts": "// a2",
848 "b.rs": "// b",
849 }),
850 )
851 .await;
852
853 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
854 project.read_with(cx, |project, _| {
855 let language_registry = project.languages();
856 language_registry.add(Arc::new(
857 Language::new(
858 LanguageConfig {
859 name: "TypeScript".into(),
860 matcher: LanguageMatcher {
861 path_suffixes: vec!["ts".to_string()],
862 ..LanguageMatcher::default()
863 },
864 ..LanguageConfig::default()
865 },
866 None,
867 )
868 .with_context_provider(Some(Arc::new(
869 ContextProviderWithTasks::new(TaskTemplates(vec![
870 TaskTemplate {
871 label: "Task without variables".to_string(),
872 command: "npm run clean".to_string(),
873 ..TaskTemplate::default()
874 },
875 TaskTemplate {
876 label: "TypeScript task from file $ZED_FILE".to_string(),
877 command: "npm run build".to_string(),
878 ..TaskTemplate::default()
879 },
880 TaskTemplate {
881 label: "Another task from file $ZED_FILE".to_string(),
882 command: "npm run lint".to_string(),
883 ..TaskTemplate::default()
884 },
885 ])),
886 ))),
887 ));
888 language_registry.add(Arc::new(
889 Language::new(
890 LanguageConfig {
891 name: "Rust".into(),
892 matcher: LanguageMatcher {
893 path_suffixes: vec!["rs".to_string()],
894 ..LanguageMatcher::default()
895 },
896 ..LanguageConfig::default()
897 },
898 None,
899 )
900 .with_context_provider(Some(Arc::new(
901 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
902 label: "Rust task".to_string(),
903 command: "cargo check".into(),
904 ..TaskTemplate::default()
905 }])),
906 ))),
907 ));
908 });
909 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
910
911 let _ts_file_1 = workspace
912 .update(cx, |workspace, cx| {
913 workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
914 })
915 .await
916 .unwrap();
917 let tasks_picker = open_spawn_tasks(&workspace, cx);
918 assert_eq!(
919 task_names(&tasks_picker, cx),
920 vec![
921 "Another task from file /dir/a1.ts",
922 "TypeScript task from file /dir/a1.ts",
923 "Task without variables",
924 ],
925 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
926 );
927 emulate_task_schedule(
928 tasks_picker,
929 &project,
930 "TypeScript task from file /dir/a1.ts",
931 cx,
932 );
933
934 let tasks_picker = open_spawn_tasks(&workspace, cx);
935 assert_eq!(
936 task_names(&tasks_picker, cx),
937 vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
938 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
939 Tasks with the same labels and context are deduplicated."
940 );
941 tasks_picker.update(cx, |_, cx| {
942 cx.emit(DismissEvent);
943 });
944 drop(tasks_picker);
945 cx.executor().run_until_parked();
946
947 let _ts_file_2 = workspace
948 .update(cx, |workspace, cx| {
949 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
950 })
951 .await
952 .unwrap();
953 let tasks_picker = open_spawn_tasks(&workspace, cx);
954 assert_eq!(
955 task_names(&tasks_picker, cx),
956 vec![
957 "TypeScript task from file /dir/a1.ts",
958 "Another task from file /dir/a2.ts",
959 "TypeScript task from file /dir/a2.ts",
960 "Task without variables"
961 ],
962 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
963 );
964 tasks_picker.update(cx, |_, cx| {
965 cx.emit(DismissEvent);
966 });
967 drop(tasks_picker);
968 cx.executor().run_until_parked();
969
970 let _rs_file = workspace
971 .update(cx, |workspace, cx| {
972 workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
973 })
974 .await
975 .unwrap();
976 let tasks_picker = open_spawn_tasks(&workspace, cx);
977 assert_eq!(
978 task_names(&tasks_picker, cx),
979 vec!["Rust task"],
980 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
981 );
982
983 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
984 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
985 let _ts_file_2 = workspace
986 .update(cx, |workspace, cx| {
987 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
988 })
989 .await
990 .unwrap();
991 let tasks_picker = open_spawn_tasks(&workspace, cx);
992 assert_eq!(
993 task_names(&tasks_picker, cx),
994 vec![
995 "TypeScript task from file /dir/a1.ts",
996 "Another task from file /dir/a2.ts",
997 "TypeScript task from file /dir/a2.ts",
998 "Task without variables"
999 ],
1000 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
1001 same TS spawn history should be restored"
1002 );
1003 }
1004
1005 fn emulate_task_schedule(
1006 tasks_picker: View<Picker<TasksModalDelegate>>,
1007 project: &Model<Project>,
1008 scheduled_task_label: &str,
1009 cx: &mut VisualTestContext,
1010 ) {
1011 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
1012 tasks_picker
1013 .delegate
1014 .candidates
1015 .iter()
1016 .flatten()
1017 .find(|(_, task)| task.resolved_label == scheduled_task_label)
1018 .cloned()
1019 .unwrap()
1020 });
1021 project.update(cx, |project, cx| {
1022 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
1023 task_inventory.update(cx, |inventory, _| {
1024 let (kind, task) = scheduled_task;
1025 inventory.task_scheduled(kind, task);
1026 });
1027 }
1028 });
1029 tasks_picker.update(cx, |_, cx| {
1030 cx.emit(DismissEvent);
1031 });
1032 drop(tasks_picker);
1033 cx.executor().run_until_parked()
1034 }
1035
1036 fn open_spawn_tasks(
1037 workspace: &View<Workspace>,
1038 cx: &mut VisualTestContext,
1039 ) -> View<Picker<TasksModalDelegate>> {
1040 cx.dispatch_action(Spawn::modal());
1041 workspace.update(cx, |workspace, cx| {
1042 workspace
1043 .active_modal::<TasksModal>(cx)
1044 .expect("no task modal after `Spawn` action was dispatched")
1045 .read(cx)
1046 .picker
1047 .clone()
1048 })
1049 }
1050
1051 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1052 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1053 }
1054
1055 fn task_names(
1056 spawn_tasks: &View<Picker<TasksModalDelegate>>,
1057 cx: &mut VisualTestContext,
1058 ) -> Vec<String> {
1059 spawn_tasks.update(cx, |spawn_tasks, _| {
1060 spawn_tasks
1061 .delegate
1062 .matches
1063 .iter()
1064 .map(|hit| hit.string.clone())
1065 .collect::<Vec<_>>()
1066 })
1067 }
1068}