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