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