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