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