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