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