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