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