1use std::{path::PathBuf, 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::{
10 highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
11 Picker, PickerDelegate,
12};
13use project::{Inventory, ProjectPath, TaskSourceKind};
14use task::{oneshot_source::OneshotSource, Task};
15use ui::{v_flex, ListItem, ListItemSpacing, RenderOnce, Selectable, WindowContext};
16use util::{paths::PathExt, ResultExt};
17use workspace::{ModalView, Workspace};
18
19use crate::schedule_task;
20
21actions!(task, [Spawn, Rerun]);
22
23/// A modal used to spawn new tasks.
24pub(crate) struct TasksModalDelegate {
25 inventory: Model<Inventory>,
26 candidates: Vec<(TaskSourceKind, Arc<dyn Task>)>,
27 matches: Vec<StringMatch>,
28 selected_index: usize,
29 workspace: WeakView<Workspace>,
30 prompt: String,
31}
32
33impl TasksModalDelegate {
34 fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
35 Self {
36 inventory,
37 workspace,
38 candidates: Vec::new(),
39 matches: Vec::new(),
40 selected_index: 0,
41 prompt: String::default(),
42 }
43 }
44
45 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
46 self.inventory
47 .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
48 .update(cx, |oneshot_source, _| {
49 Some(
50 oneshot_source
51 .as_any()
52 .downcast_mut::<OneshotSource>()?
53 .spawn(self.prompt.clone()),
54 )
55 })
56 }
57
58 fn active_item_path(
59 &mut self,
60 cx: &mut ViewContext<'_, Picker<Self>>,
61 ) -> Option<(PathBuf, ProjectPath)> {
62 let workspace = self.workspace.upgrade()?.read(cx);
63 let project = workspace.project().read(cx);
64 let active_item = workspace.active_item(cx)?;
65 active_item.project_path(cx).and_then(|project_path| {
66 project
67 .worktree_for_id(project_path.worktree_id, cx)
68 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
69 .zip(Some(project_path))
70 })
71 }
72}
73
74pub(crate) struct TasksModal {
75 picker: View<Picker<TasksModalDelegate>>,
76 _subscription: Subscription,
77}
78
79impl TasksModal {
80 pub(crate) fn new(
81 inventory: Model<Inventory>,
82 workspace: WeakView<Workspace>,
83 cx: &mut ViewContext<Self>,
84 ) -> Self {
85 let picker = cx
86 .new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
87 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
88 cx.emit(DismissEvent);
89 });
90 Self {
91 picker,
92 _subscription,
93 }
94 }
95}
96
97impl Render for TasksModal {
98 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
99 v_flex()
100 .key_context("TasksModal")
101 .w(rems(34.))
102 .child(self.picker.clone())
103 .on_mouse_down_out(cx.listener(|modal, _, cx| {
104 modal.picker.update(cx, |picker, cx| {
105 picker.cancel(&Default::default(), cx);
106 })
107 }))
108 }
109}
110
111impl EventEmitter<DismissEvent> for TasksModal {}
112
113impl FocusableView for TasksModal {
114 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
115 self.picker.read(cx).focus_handle(cx)
116 }
117}
118
119impl ModalView for TasksModal {}
120
121impl PickerDelegate for TasksModalDelegate {
122 type ListItem = ListItem;
123
124 fn match_count(&self) -> usize {
125 self.matches.len()
126 }
127
128 fn selected_index(&self) -> usize {
129 self.selected_index
130 }
131
132 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
133 self.selected_index = ix;
134 }
135
136 fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
137 Arc::from(format!(
138 "{} use task name as prompt, {} spawns a bash-like task from the prompt, {} runs the selected task",
139 cx.keystroke_text_for(&menu::UseSelectedQuery),
140 cx.keystroke_text_for(&menu::SecondaryConfirm),
141 cx.keystroke_text_for(&menu::Confirm),
142 ))
143 }
144
145 fn update_matches(
146 &mut self,
147 query: String,
148 cx: &mut ViewContext<picker::Picker<Self>>,
149 ) -> gpui::Task<()> {
150 cx.spawn(move |picker, mut cx| async move {
151 let Some(candidates) = picker
152 .update(&mut cx, |picker, cx| {
153 let (path, worktree) = match picker.delegate.active_item_path(cx) {
154 Some((abs_path, project_path)) => {
155 (Some(abs_path), Some(project_path.worktree_id))
156 }
157 None => (None, None),
158 };
159 picker.delegate.candidates =
160 picker.delegate.inventory.update(cx, |inventory, cx| {
161 inventory.list_tasks(path.as_deref(), worktree, true, cx)
162 });
163 picker
164 .delegate
165 .candidates
166 .iter()
167 .enumerate()
168 .map(|(index, (_, candidate))| StringMatchCandidate {
169 id: index,
170 char_bag: candidate.name().chars().collect(),
171 string: candidate.name().into(),
172 })
173 .collect::<Vec<_>>()
174 })
175 .ok()
176 else {
177 return;
178 };
179 let matches = fuzzy::match_strings(
180 &candidates,
181 &query,
182 true,
183 1000,
184 &Default::default(),
185 cx.background_executor().clone(),
186 )
187 .await;
188 picker
189 .update(&mut cx, |picker, _| {
190 let delegate = &mut picker.delegate;
191 delegate.matches = matches;
192 delegate.prompt = query;
193
194 if delegate.matches.is_empty() {
195 delegate.selected_index = 0;
196 } else {
197 delegate.selected_index =
198 delegate.selected_index.min(delegate.matches.len() - 1);
199 }
200 })
201 .log_err();
202 })
203 }
204
205 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
206 let current_match_index = self.selected_index();
207 let task = if secondary {
208 if !self.prompt.trim().is_empty() {
209 self.spawn_oneshot(cx)
210 } else {
211 None
212 }
213 } else {
214 self.matches.get(current_match_index).map(|current_match| {
215 let ix = current_match.candidate_id;
216 self.candidates[ix].1.clone()
217 })
218 };
219
220 let Some(task) = task else {
221 return;
222 };
223
224 self.workspace
225 .update(cx, |workspace, cx| {
226 schedule_task(workspace, task.as_ref(), cx);
227 })
228 .ok();
229 cx.emit(DismissEvent);
230 }
231
232 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
233 cx.emit(DismissEvent);
234 }
235
236 fn render_match(
237 &self,
238 ix: usize,
239 selected: bool,
240 cx: &mut ViewContext<picker::Picker<Self>>,
241 ) -> Option<Self::ListItem> {
242 let hit = &self.matches[ix];
243 let (source_kind, _) = &self.candidates[hit.candidate_id];
244 let details = match source_kind {
245 TaskSourceKind::UserInput => "user input".to_string(),
246 TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
247 abs_path.compact().to_string_lossy().to_string()
248 }
249 };
250
251 let highlighted_location = HighlightedMatchWithPaths {
252 match_label: HighlightedText {
253 text: hit.string.clone(),
254 highlight_positions: hit.positions.clone(),
255 char_count: hit.string.chars().count(),
256 },
257 paths: vec![HighlightedText {
258 char_count: details.chars().count(),
259 highlight_positions: Vec::new(),
260 text: details,
261 }],
262 };
263 Some(
264 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
265 .inset(true)
266 .spacing(ListItemSpacing::Sparse)
267 .selected(selected)
268 .child(highlighted_location.render(cx)),
269 )
270 }
271
272 fn selected_as_query(&self) -> Option<String> {
273 Some(self.matches.get(self.selected_index())?.string.clone())
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use gpui::{TestAppContext, VisualTestContext};
280 use project::{FakeFs, Project};
281 use serde_json::json;
282 use workspace::AppState;
283
284 use super::*;
285
286 #[gpui::test]
287 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
288 init_test(cx);
289 let fs = FakeFs::new(cx.executor());
290 fs.insert_tree(
291 "/dir",
292 json!({
293 ".zed": {
294 "tasks.json": r#"[
295 {
296 "label": "example task",
297 "command": "echo",
298 "args": ["4"]
299 },
300 {
301 "label": "another one",
302 "command": "echo",
303 "args": ["55"]
304 },
305 ]"#,
306 },
307 "a.ts": "a"
308 }),
309 )
310 .await;
311
312 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
313 project.update(cx, |project, cx| {
314 project.task_inventory().update(cx, |inventory, cx| {
315 inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
316 })
317 });
318
319 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
320
321 let tasks_picker = open_spawn_tasks(&workspace, cx);
322 assert_eq!(
323 query(&tasks_picker, cx),
324 "",
325 "Initial query should be empty"
326 );
327 assert_eq!(
328 task_names(&tasks_picker, cx),
329 vec!["another one", "example task"],
330 "Initial tasks should be listed in alphabetical order"
331 );
332
333 let query_str = "tas";
334 cx.simulate_input(query_str);
335 assert_eq!(query(&tasks_picker, cx), query_str);
336 assert_eq!(
337 task_names(&tasks_picker, cx),
338 vec!["example task"],
339 "Only one task should match the query {query_str}"
340 );
341
342 cx.dispatch_action(menu::UseSelectedQuery);
343 assert_eq!(
344 query(&tasks_picker, cx),
345 "example task",
346 "Query should be set to the selected task's name"
347 );
348 assert_eq!(
349 task_names(&tasks_picker, cx),
350 vec!["example task"],
351 "No other tasks should be listed"
352 );
353 cx.dispatch_action(menu::Confirm);
354
355 let tasks_picker = open_spawn_tasks(&workspace, cx);
356 assert_eq!(
357 query(&tasks_picker, cx),
358 "",
359 "Query should be reset after confirming"
360 );
361 assert_eq!(
362 task_names(&tasks_picker, cx),
363 vec!["example task", "another one"],
364 "Last recently used task should be listed first"
365 );
366
367 let query_str = "echo 4";
368 cx.simulate_input(query_str);
369 assert_eq!(query(&tasks_picker, cx), query_str);
370 assert_eq!(
371 task_names(&tasks_picker, cx),
372 Vec::<String>::new(),
373 "No tasks should match custom command query"
374 );
375
376 cx.dispatch_action(menu::SecondaryConfirm);
377 let tasks_picker = open_spawn_tasks(&workspace, cx);
378 assert_eq!(
379 query(&tasks_picker, cx),
380 "",
381 "Query should be reset after confirming"
382 );
383 assert_eq!(
384 task_names(&tasks_picker, cx),
385 vec![query_str, "example task", "another one"],
386 "Last recently used one show task should be listed first"
387 );
388
389 cx.dispatch_action(menu::UseSelectedQuery);
390 assert_eq!(
391 query(&tasks_picker, cx),
392 query_str,
393 "Query should be set to the custom task's name"
394 );
395 assert_eq!(
396 task_names(&tasks_picker, cx),
397 vec![query_str],
398 "Only custom task should be listed"
399 );
400 }
401
402 fn open_spawn_tasks(
403 workspace: &View<Workspace>,
404 cx: &mut VisualTestContext,
405 ) -> View<Picker<TasksModalDelegate>> {
406 cx.dispatch_action(crate::modal::Spawn);
407 workspace.update(cx, |workspace, cx| {
408 workspace
409 .active_modal::<TasksModal>(cx)
410 .unwrap()
411 .read(cx)
412 .picker
413 .clone()
414 })
415 }
416
417 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
418 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
419 }
420
421 fn task_names(
422 spawn_tasks: &View<Picker<TasksModalDelegate>>,
423 cx: &mut VisualTestContext,
424 ) -> Vec<String> {
425 spawn_tasks.update(cx, |spawn_tasks, _| {
426 spawn_tasks
427 .delegate
428 .matches
429 .iter()
430 .map(|hit| hit.string.clone())
431 .collect::<Vec<_>>()
432 })
433 }
434
435 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
436 cx.update(|cx| {
437 let state = AppState::test(cx);
438 language::init(cx);
439 crate::init(cx);
440 editor::init(cx);
441 workspace::init_settings(cx);
442 Project::init_settings(cx);
443 state
444 })
445 }
446}