1use std::sync::Arc;
2
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
6 Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
7 VisualContext, WeakView,
8};
9use picker::{Picker, PickerDelegate};
10use project::Inventory;
11use task::{oneshot_source::OneshotSource, Task};
12use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable, WindowContext};
13use util::ResultExt;
14use workspace::{ModalView, Workspace};
15
16use crate::schedule_task;
17
18actions!(task, [Spawn, Rerun]);
19
20/// A modal used to spawn new tasks.
21pub(crate) struct TasksModalDelegate {
22 inventory: Model<Inventory>,
23 candidates: Vec<Arc<dyn Task>>,
24 matches: Vec<StringMatch>,
25 selected_index: usize,
26 workspace: WeakView<Workspace>,
27 prompt: String,
28}
29
30impl TasksModalDelegate {
31 fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
32 Self {
33 inventory,
34 workspace,
35 candidates: Vec::new(),
36 matches: Vec::new(),
37 selected_index: 0,
38 prompt: String::default(),
39 }
40 }
41
42 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
43 self.inventory
44 .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
45 .update(cx, |oneshot_source, _| {
46 Some(
47 oneshot_source
48 .as_any()
49 .downcast_mut::<OneshotSource>()?
50 .spawn(self.prompt.clone()),
51 )
52 })
53 }
54}
55
56pub(crate) struct TasksModal {
57 picker: View<Picker<TasksModalDelegate>>,
58 _subscription: Subscription,
59}
60
61impl TasksModal {
62 pub(crate) fn new(
63 inventory: Model<Inventory>,
64 workspace: WeakView<Workspace>,
65 cx: &mut ViewContext<Self>,
66 ) -> Self {
67 let picker = cx
68 .new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
69 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
70 cx.emit(DismissEvent);
71 });
72 Self {
73 picker,
74 _subscription,
75 }
76 }
77}
78
79impl Render for TasksModal {
80 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
81 v_flex()
82 .w(rems(34.))
83 .child(self.picker.clone())
84 .on_mouse_down_out(cx.listener(|modal, _, cx| {
85 modal.picker.update(cx, |picker, cx| {
86 picker.cancel(&Default::default(), cx);
87 })
88 }))
89 }
90}
91
92impl EventEmitter<DismissEvent> for TasksModal {}
93
94impl FocusableView for TasksModal {
95 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
96 self.picker.read(cx).focus_handle(cx)
97 }
98}
99
100impl ModalView for TasksModal {}
101
102impl PickerDelegate for TasksModalDelegate {
103 type ListItem = ListItem;
104
105 fn match_count(&self) -> usize {
106 self.matches.len()
107 }
108
109 fn selected_index(&self) -> usize {
110 self.selected_index
111 }
112
113 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
114 self.selected_index = ix;
115 }
116
117 fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
118 Arc::from(format!(
119 "{} runs the selected task, {} spawns a bash-like task from the prompt",
120 cx.keystroke_text_for(&menu::Confirm),
121 cx.keystroke_text_for(&menu::SecondaryConfirm),
122 ))
123 }
124
125 fn update_matches(
126 &mut self,
127 query: String,
128 cx: &mut ViewContext<picker::Picker<Self>>,
129 ) -> gpui::Task<()> {
130 cx.spawn(move |picker, mut cx| async move {
131 let Some(candidates) = picker
132 .update(&mut cx, |picker, cx| {
133 picker.delegate.candidates = picker
134 .delegate
135 .inventory
136 .update(cx, |inventory, cx| inventory.list_tasks(None, true, cx));
137 picker
138 .delegate
139 .candidates
140 .iter()
141 .enumerate()
142 .map(|(index, candidate)| StringMatchCandidate {
143 id: index,
144 char_bag: candidate.name().chars().collect(),
145 string: candidate.name().into(),
146 })
147 .collect::<Vec<_>>()
148 })
149 .ok()
150 else {
151 return;
152 };
153 let matches = fuzzy::match_strings(
154 &candidates,
155 &query,
156 true,
157 1000,
158 &Default::default(),
159 cx.background_executor().clone(),
160 )
161 .await;
162 picker
163 .update(&mut cx, |picker, _| {
164 let delegate = &mut picker.delegate;
165 delegate.matches = matches;
166 delegate.prompt = query;
167
168 if delegate.matches.is_empty() {
169 delegate.selected_index = 0;
170 } else {
171 delegate.selected_index =
172 delegate.selected_index.min(delegate.matches.len() - 1);
173 }
174 })
175 .log_err();
176 })
177 }
178
179 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
180 let current_match_index = self.selected_index();
181
182 let task = if secondary {
183 if !self.prompt.trim().is_empty() {
184 self.spawn_oneshot(cx)
185 } else {
186 None
187 }
188 } else {
189 self.matches.get(current_match_index).map(|current_match| {
190 let ix = current_match.candidate_id;
191 self.candidates[ix].clone()
192 })
193 };
194
195 let Some(task) = task else {
196 return;
197 };
198
199 self.workspace
200 .update(cx, |workspace, cx| {
201 schedule_task(workspace, task.as_ref(), cx);
202 })
203 .ok();
204 cx.emit(DismissEvent);
205 }
206
207 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
208 cx.emit(DismissEvent);
209 }
210
211 fn render_match(
212 &self,
213 ix: usize,
214 selected: bool,
215 _cx: &mut ViewContext<picker::Picker<Self>>,
216 ) -> Option<Self::ListItem> {
217 let hit = &self.matches[ix];
218 let highlights: Vec<_> = hit.positions.iter().copied().collect();
219 Some(
220 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
221 .inset(true)
222 .spacing(ListItemSpacing::Sparse)
223 .selected(selected)
224 .start_slot(HighlightedLabel::new(hit.string.clone(), highlights)),
225 )
226 }
227}