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