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