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, Selectable, 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, &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 .selected(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().to_owned(),
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 });
688 let tasks_picker = workspace.update(cx, |workspace, cx| {
689 workspace
690 .active_modal::<TasksModal>(cx)
691 .unwrap()
692 .read(cx)
693 .picker
694 .clone()
695 });
696 assert_eq!(
697 task_names(&tasks_picker, cx),
698 vec!["echo 4", "another one", "example task"],
699 );
700 }
701
702 #[gpui::test]
703 async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) {
704 init_test(cx);
705 let fs = FakeFs::new(cx.executor());
706 fs.insert_tree(
707 "/dir",
708 json!({
709 ".zed": {
710 "tasks.json": r#"[
711 {
712 "label": "hello from $ZED_FILE:$ZED_ROW:$ZED_COLUMN",
713 "command": "echo",
714 "args": ["hello", "from", "$ZED_FILE", ":", "$ZED_ROW", ":", "$ZED_COLUMN"]
715 },
716 {
717 "label": "opened now: $ZED_WORKTREE_ROOT",
718 "command": "echo",
719 "args": ["opened", "now:", "$ZED_WORKTREE_ROOT"]
720 }
721 ]"#,
722 },
723 "file_without_extension": "aaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaa",
724 "file_with.odd_extension": "b",
725 }),
726 )
727 .await;
728
729 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
730 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
731
732 let tasks_picker = open_spawn_tasks(&workspace, cx);
733 assert_eq!(
734 task_names(&tasks_picker, cx),
735 Vec::<String>::new(),
736 "Should list no file or worktree context-dependent when no file is open"
737 );
738 tasks_picker.update(cx, |_, cx| {
739 cx.emit(DismissEvent);
740 });
741 drop(tasks_picker);
742 cx.executor().run_until_parked();
743
744 let _ = workspace
745 .update(cx, |workspace, cx| {
746 workspace.open_abs_path(PathBuf::from("/dir/file_with.odd_extension"), true, cx)
747 })
748 .await
749 .unwrap();
750 cx.executor().run_until_parked();
751 let tasks_picker = open_spawn_tasks(&workspace, cx);
752 assert_eq!(
753 task_names(&tasks_picker, cx),
754 vec![
755 "hello from …th.odd_extension:1:1".to_string(),
756 "opened now: /dir".to_string()
757 ],
758 "Second opened buffer should fill the context, labels should be trimmed if long enough"
759 );
760 tasks_picker.update(cx, |_, cx| {
761 cx.emit(DismissEvent);
762 });
763 drop(tasks_picker);
764 cx.executor().run_until_parked();
765
766 let second_item = workspace
767 .update(cx, |workspace, cx| {
768 workspace.open_abs_path(PathBuf::from("/dir/file_without_extension"), true, cx)
769 })
770 .await
771 .unwrap();
772
773 let editor = cx.update(|cx| second_item.act_as::<Editor>(cx)).unwrap();
774 editor.update(cx, |editor, cx| {
775 editor.change_selections(None, cx, |s| {
776 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
777 })
778 });
779 cx.executor().run_until_parked();
780 let tasks_picker = open_spawn_tasks(&workspace, cx);
781 assert_eq!(
782 task_names(&tasks_picker, cx),
783 vec![
784 "hello from …ithout_extension:2:3".to_string(),
785 "opened now: /dir".to_string()
786 ],
787 "Opened buffer should fill the context, labels should be trimmed if long enough"
788 );
789 tasks_picker.update(cx, |_, cx| {
790 cx.emit(DismissEvent);
791 });
792 drop(tasks_picker);
793 cx.executor().run_until_parked();
794 }
795
796 #[gpui::test]
797 async fn test_language_task_filtering(cx: &mut TestAppContext) {
798 init_test(cx);
799 let fs = FakeFs::new(cx.executor());
800 fs.insert_tree(
801 "/dir",
802 json!({
803 "a1.ts": "// a1",
804 "a2.ts": "// a2",
805 "b.rs": "// b",
806 }),
807 )
808 .await;
809
810 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
811 project.read_with(cx, |project, _| {
812 let language_registry = project.languages();
813 language_registry.add(Arc::new(
814 Language::new(
815 LanguageConfig {
816 name: "TypeScript".into(),
817 matcher: LanguageMatcher {
818 path_suffixes: vec!["ts".to_string()],
819 ..LanguageMatcher::default()
820 },
821 ..LanguageConfig::default()
822 },
823 None,
824 )
825 .with_context_provider(Some(Arc::new(
826 ContextProviderWithTasks::new(TaskTemplates(vec![
827 TaskTemplate {
828 label: "Task without variables".to_string(),
829 command: "npm run clean".to_string(),
830 ..TaskTemplate::default()
831 },
832 TaskTemplate {
833 label: "TypeScript task from file $ZED_FILE".to_string(),
834 command: "npm run build".to_string(),
835 ..TaskTemplate::default()
836 },
837 TaskTemplate {
838 label: "Another task from file $ZED_FILE".to_string(),
839 command: "npm run lint".to_string(),
840 ..TaskTemplate::default()
841 },
842 ])),
843 ))),
844 ));
845 language_registry.add(Arc::new(
846 Language::new(
847 LanguageConfig {
848 name: "Rust".into(),
849 matcher: LanguageMatcher {
850 path_suffixes: vec!["rs".to_string()],
851 ..LanguageMatcher::default()
852 },
853 ..LanguageConfig::default()
854 },
855 None,
856 )
857 .with_context_provider(Some(Arc::new(
858 ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate {
859 label: "Rust task".to_string(),
860 command: "cargo check".into(),
861 ..TaskTemplate::default()
862 }])),
863 ))),
864 ));
865 });
866 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
867
868 let _ts_file_1 = workspace
869 .update(cx, |workspace, cx| {
870 workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx)
871 })
872 .await
873 .unwrap();
874 let tasks_picker = open_spawn_tasks(&workspace, cx);
875 assert_eq!(
876 task_names(&tasks_picker, cx),
877 vec![
878 "Another task from file /dir/a1.ts",
879 "TypeScript task from file /dir/a1.ts",
880 "Task without variables",
881 ],
882 "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically"
883 );
884 emulate_task_schedule(
885 tasks_picker,
886 &project,
887 "TypeScript task from file /dir/a1.ts",
888 cx,
889 );
890
891 let tasks_picker = open_spawn_tasks(&workspace, cx);
892 assert_eq!(
893 task_names(&tasks_picker, cx),
894 vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"],
895 "After spawning the task and getting it into the history, it should be up in the sort as recently used.
896 Tasks with the same labels and context are deduplicated."
897 );
898 tasks_picker.update(cx, |_, cx| {
899 cx.emit(DismissEvent);
900 });
901 drop(tasks_picker);
902 cx.executor().run_until_parked();
903
904 let _ts_file_2 = workspace
905 .update(cx, |workspace, cx| {
906 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
907 })
908 .await
909 .unwrap();
910 let tasks_picker = open_spawn_tasks(&workspace, cx);
911 assert_eq!(
912 task_names(&tasks_picker, cx),
913 vec![
914 "TypeScript task from file /dir/a1.ts",
915 "Another task from file /dir/a2.ts",
916 "TypeScript task from file /dir/a2.ts",
917 "Task without variables"
918 ],
919 "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file"
920 );
921 tasks_picker.update(cx, |_, cx| {
922 cx.emit(DismissEvent);
923 });
924 drop(tasks_picker);
925 cx.executor().run_until_parked();
926
927 let _rs_file = workspace
928 .update(cx, |workspace, cx| {
929 workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx)
930 })
931 .await
932 .unwrap();
933 let tasks_picker = open_spawn_tasks(&workspace, cx);
934 assert_eq!(
935 task_names(&tasks_picker, cx),
936 vec!["Rust task"],
937 "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only"
938 );
939
940 cx.dispatch_action(CloseInactiveTabsAndPanes::default());
941 emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
942 let _ts_file_2 = workspace
943 .update(cx, |workspace, cx| {
944 workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx)
945 })
946 .await
947 .unwrap();
948 let tasks_picker = open_spawn_tasks(&workspace, cx);
949 assert_eq!(
950 task_names(&tasks_picker, cx),
951 vec![
952 "TypeScript task from file /dir/a1.ts",
953 "Another task from file /dir/a2.ts",
954 "TypeScript task from file /dir/a2.ts",
955 "Task without variables"
956 ],
957 "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \
958 same TS spawn history should be restored"
959 );
960 }
961
962 fn emulate_task_schedule(
963 tasks_picker: View<Picker<TasksModalDelegate>>,
964 project: &Model<Project>,
965 scheduled_task_label: &str,
966 cx: &mut VisualTestContext,
967 ) {
968 let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
969 tasks_picker
970 .delegate
971 .candidates
972 .iter()
973 .flatten()
974 .find(|(_, task)| task.resolved_label == scheduled_task_label)
975 .cloned()
976 .unwrap()
977 });
978 project.update(cx, |project, cx| {
979 if let Some(task_inventory) = project.task_store().read(cx).task_inventory().cloned() {
980 task_inventory.update(cx, |inventory, _| {
981 let (kind, task) = scheduled_task;
982 inventory.task_scheduled(kind, task);
983 });
984 }
985 });
986 tasks_picker.update(cx, |_, cx| {
987 cx.emit(DismissEvent);
988 });
989 drop(tasks_picker);
990 cx.executor().run_until_parked()
991 }
992
993 fn open_spawn_tasks(
994 workspace: &View<Workspace>,
995 cx: &mut VisualTestContext,
996 ) -> View<Picker<TasksModalDelegate>> {
997 cx.dispatch_action(Spawn::default());
998 workspace.update(cx, |workspace, cx| {
999 workspace
1000 .active_modal::<TasksModal>(cx)
1001 .expect("no task modal after `Spawn` action was dispatched")
1002 .read(cx)
1003 .picker
1004 .clone()
1005 })
1006 }
1007
1008 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
1009 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
1010 }
1011
1012 fn task_names(
1013 spawn_tasks: &View<Picker<TasksModalDelegate>>,
1014 cx: &mut VisualTestContext,
1015 ) -> Vec<String> {
1016 spawn_tasks.update(cx, |spawn_tasks, _| {
1017 spawn_tasks
1018 .delegate
1019 .matches
1020 .iter()
1021 .map(|hit| hit.string.clone())
1022 .collect::<Vec<_>>()
1023 })
1024 }
1025}