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