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::{
16 div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, IconButton,
17 IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable,
18 Tooltip, WindowContext,
19};
20use util::{paths::PathExt, ResultExt};
21use workspace::{ModalView, Workspace};
22
23use crate::schedule_task;
24use serde::Deserialize;
25
26/// Spawn a task with name or open tasks modal
27#[derive(PartialEq, Clone, Deserialize, Default)]
28pub struct Spawn {
29 #[serde(default)]
30 /// Name of the task to spawn.
31 /// If it is not set, a modal with a list of available tasks is opened instead.
32 /// Defaults to None.
33 pub task_name: Option<String>,
34}
35
36/// Rerun last task
37#[derive(PartialEq, Clone, Deserialize, Default)]
38pub struct Rerun {
39 #[serde(default)]
40 /// Controls whether the task context is reevaluated prior to execution of a task.
41 /// 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
42 /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
43 /// default: false
44 pub reevaluate_context: bool,
45}
46
47impl_actions!(task, [Rerun, Spawn]);
48
49/// A modal used to spawn new tasks.
50pub(crate) struct TasksModalDelegate {
51 inventory: Model<Inventory>,
52 candidates: Option<Vec<(TaskSourceKind, Arc<dyn Task>)>>,
53 matches: Vec<StringMatch>,
54 selected_index: usize,
55 workspace: WeakView<Workspace>,
56 prompt: String,
57 task_context: TaskContext,
58}
59
60impl TasksModalDelegate {
61 fn new(
62 inventory: Model<Inventory>,
63 task_context: TaskContext,
64 workspace: WeakView<Workspace>,
65 ) -> Self {
66 Self {
67 inventory,
68 workspace,
69 candidates: None,
70 matches: Vec::new(),
71 selected_index: 0,
72 prompt: String::default(),
73 task_context,
74 }
75 }
76
77 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
78 self.inventory
79 .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
80 .update(cx, |oneshot_source, _| {
81 Some(
82 oneshot_source
83 .as_any()
84 .downcast_mut::<OneshotSource>()?
85 .spawn(self.prompt.clone()),
86 )
87 })
88 }
89
90 fn delete_oneshot(&mut self, ix: usize, cx: &mut AppContext) {
91 let Some(candidates) = self.candidates.as_mut() else {
92 return;
93 };
94 let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
95 return;
96 };
97 // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
98 // 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
99 // the original list without a removed entry.
100 candidates.remove(ix);
101 self.inventory.update(cx, |inventory, cx| {
102 let oneshot_source = inventory.source::<OneshotSource>()?;
103 let task_id = task.id();
104
105 oneshot_source.update(cx, |this, _| {
106 let oneshot_source = this.as_any().downcast_mut::<OneshotSource>()?;
107 oneshot_source.remove(task_id);
108 Some(())
109 });
110 Some(())
111 });
112 }
113 fn active_item_path(
114 workspace: &WeakView<Workspace>,
115 cx: &mut ViewContext<'_, Picker<Self>>,
116 ) -> Option<(PathBuf, ProjectPath)> {
117 let workspace = workspace.upgrade()?.read(cx);
118 let project = workspace.project().read(cx);
119 let active_item = workspace.active_item(cx)?;
120 active_item.project_path(cx).and_then(|project_path| {
121 project
122 .worktree_for_id(project_path.worktree_id, cx)
123 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
124 .zip(Some(project_path))
125 })
126 }
127}
128
129pub(crate) struct TasksModal {
130 picker: View<Picker<TasksModalDelegate>>,
131 _subscription: Subscription,
132}
133
134impl TasksModal {
135 pub(crate) fn new(
136 inventory: Model<Inventory>,
137 task_context: TaskContext,
138 workspace: WeakView<Workspace>,
139 cx: &mut ViewContext<Self>,
140 ) -> Self {
141 let picker = cx.new_view(|cx| {
142 Picker::uniform_list(
143 TasksModalDelegate::new(inventory, task_context, workspace),
144 cx,
145 )
146 });
147 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
148 cx.emit(DismissEvent);
149 });
150 Self {
151 picker,
152 _subscription,
153 }
154 }
155}
156
157impl Render for TasksModal {
158 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
159 v_flex()
160 .key_context("TasksModal")
161 .w(rems(34.))
162 .child(self.picker.clone())
163 .on_mouse_down_out(cx.listener(|modal, _, cx| {
164 modal.picker.update(cx, |picker, cx| {
165 picker.cancel(&Default::default(), cx);
166 })
167 }))
168 }
169}
170
171impl EventEmitter<DismissEvent> for TasksModal {}
172
173impl FocusableView for TasksModal {
174 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
175 self.picker.read(cx).focus_handle(cx)
176 }
177}
178
179impl ModalView for TasksModal {}
180
181impl PickerDelegate for TasksModalDelegate {
182 type ListItem = ListItem;
183
184 fn match_count(&self) -> usize {
185 self.matches.len()
186 }
187
188 fn selected_index(&self) -> usize {
189 self.selected_index
190 }
191
192 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
193 self.selected_index = ix;
194 }
195
196 fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
197 Arc::from(format!(
198 "{} use task name as prompt, {} spawns a bash-like task from the prompt, {} runs the selected task",
199 cx.keystroke_text_for(&picker::UseSelectedQuery),
200 cx.keystroke_text_for(&picker::ConfirmInput {secondary: false}),
201 cx.keystroke_text_for(&menu::Confirm),
202 ))
203 }
204
205 fn update_matches(
206 &mut self,
207 query: String,
208 cx: &mut ViewContext<picker::Picker<Self>>,
209 ) -> gpui::Task<()> {
210 cx.spawn(move |picker, mut cx| async move {
211 let Some(candidates) = picker
212 .update(&mut cx, |picker, cx| {
213 let candidates = picker.delegate.candidates.get_or_insert_with(|| {
214 let (path, worktree) =
215 match Self::active_item_path(&picker.delegate.workspace, cx) {
216 Some((abs_path, project_path)) => {
217 (Some(abs_path), Some(project_path.worktree_id))
218 }
219 None => (None, None),
220 };
221 picker.delegate.inventory.update(cx, |inventory, cx| {
222 inventory.list_tasks(path.as_deref(), worktree, true, cx)
223 })
224 });
225
226 candidates
227 .iter()
228 .enumerate()
229 .map(|(index, (_, candidate))| StringMatchCandidate {
230 id: index,
231 char_bag: candidate.name().chars().collect(),
232 string: candidate.name().into(),
233 })
234 .collect::<Vec<_>>()
235 })
236 .ok()
237 else {
238 return;
239 };
240 let matches = fuzzy::match_strings(
241 &candidates,
242 &query,
243 true,
244 1000,
245 &Default::default(),
246 cx.background_executor().clone(),
247 )
248 .await;
249 picker
250 .update(&mut cx, |picker, _| {
251 let delegate = &mut picker.delegate;
252 delegate.matches = matches;
253 delegate.prompt = query;
254
255 if delegate.matches.is_empty() {
256 delegate.selected_index = 0;
257 } else {
258 delegate.selected_index =
259 delegate.selected_index.min(delegate.matches.len() - 1);
260 }
261 })
262 .log_err();
263 })
264 }
265
266 fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
267 let current_match_index = self.selected_index();
268 let task = self
269 .matches
270 .get(current_match_index)
271 .and_then(|current_match| {
272 let ix = current_match.candidate_id;
273 self.candidates
274 .as_ref()
275 .map(|candidates| candidates[ix].1.clone())
276 });
277 let Some(task) = task else {
278 return;
279 };
280
281 self.workspace
282 .update(cx, |workspace, cx| {
283 schedule_task(
284 workspace,
285 task.as_ref(),
286 self.task_context.clone(),
287 omit_history_entry,
288 cx,
289 );
290 })
291 .ok();
292 cx.emit(DismissEvent);
293 }
294
295 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
296 cx.emit(DismissEvent);
297 }
298
299 fn render_match(
300 &self,
301 ix: usize,
302 selected: bool,
303 cx: &mut ViewContext<picker::Picker<Self>>,
304 ) -> Option<Self::ListItem> {
305 let candidates = self.candidates.as_ref()?;
306 let hit = &self.matches[ix];
307 let (source_kind, _) = &candidates[hit.candidate_id];
308 let details = match source_kind {
309 TaskSourceKind::UserInput => "user input".to_string(),
310 TaskSourceKind::Buffer => "language extension".to_string(),
311 TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
312 abs_path.compact().to_string_lossy().to_string()
313 }
314 };
315
316 let highlighted_location = HighlightedMatchWithPaths {
317 match_label: HighlightedText {
318 text: hit.string.clone(),
319 highlight_positions: hit.positions.clone(),
320 char_count: hit.string.chars().count(),
321 },
322 paths: vec![HighlightedText {
323 char_count: details.chars().count(),
324 highlight_positions: Vec::new(),
325 text: details,
326 }],
327 };
328 Some(
329 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
330 .inset(true)
331 .spacing(ListItemSpacing::Sparse)
332 .map(|this| {
333 if matches!(source_kind, TaskSourceKind::UserInput) {
334 let task_index = hit.candidate_id;
335 let delete_button = div().child(
336 IconButton::new("delete", IconName::Close)
337 .shape(IconButtonShape::Square)
338 .icon_color(Color::Muted)
339 .size(ButtonSize::None)
340 .icon_size(IconSize::XSmall)
341 .on_click(cx.listener(move |this, _event, cx| {
342 cx.stop_propagation();
343 cx.prevent_default();
344
345 this.delegate.delete_oneshot(task_index, cx);
346 this.refresh(cx);
347 }))
348 .tooltip(|cx| Tooltip::text("Delete an one-shot task", cx)),
349 );
350 this.end_hover_slot(delete_button)
351 } else {
352 this
353 }
354 })
355 .selected(selected)
356 .child(highlighted_location.render(cx)),
357 )
358 }
359
360 fn selected_as_query(&self) -> Option<String> {
361 use itertools::intersperse;
362 let task_index = self.matches.get(self.selected_index())?.candidate_id;
363 let tasks = self.candidates.as_ref()?;
364 let (_, task) = tasks.get(task_index)?;
365 // .exec doesn't actually spawn anything; it merely prepares a spawning command,
366 // which we can use for substitution.
367 let mut spawn_prompt = task.exec(self.task_context.clone())?;
368 if !spawn_prompt.args.is_empty() {
369 spawn_prompt.command.push(' ');
370 spawn_prompt
371 .command
372 .extend(intersperse(spawn_prompt.args, " ".to_string()));
373 }
374 Some(spawn_prompt.command)
375 }
376 fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
377 let Some(task) = self.spawn_oneshot(cx) else {
378 return;
379 };
380 self.workspace
381 .update(cx, |workspace, cx| {
382 schedule_task(
383 workspace,
384 task.as_ref(),
385 self.task_context.clone(),
386 omit_history_entry,
387 cx,
388 );
389 })
390 .ok();
391 cx.emit(DismissEvent);
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use gpui::{TestAppContext, VisualTestContext};
398 use project::{FakeFs, Project};
399 use serde_json::json;
400
401 use super::*;
402
403 #[gpui::test]
404 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
405 crate::tests::init_test(cx);
406 let fs = FakeFs::new(cx.executor());
407 fs.insert_tree(
408 "/dir",
409 json!({
410 ".zed": {
411 "tasks.json": r#"[
412 {
413 "label": "example task",
414 "command": "echo",
415 "args": ["4"]
416 },
417 {
418 "label": "another one",
419 "command": "echo",
420 "args": ["55"]
421 },
422 ]"#,
423 },
424 "a.ts": "a"
425 }),
426 )
427 .await;
428
429 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
430 project.update(cx, |project, cx| {
431 project.task_inventory().update(cx, |inventory, cx| {
432 inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
433 })
434 });
435
436 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
437
438 let tasks_picker = open_spawn_tasks(&workspace, cx);
439 assert_eq!(
440 query(&tasks_picker, cx),
441 "",
442 "Initial query should be empty"
443 );
444 assert_eq!(
445 task_names(&tasks_picker, cx),
446 vec!["another one", "example task"],
447 "Initial tasks should be listed in alphabetical order"
448 );
449
450 let query_str = "tas";
451 cx.simulate_input(query_str);
452 assert_eq!(query(&tasks_picker, cx), query_str);
453 assert_eq!(
454 task_names(&tasks_picker, cx),
455 vec!["example task"],
456 "Only one task should match the query {query_str}"
457 );
458
459 cx.dispatch_action(picker::UseSelectedQuery);
460 assert_eq!(
461 query(&tasks_picker, cx),
462 "echo 4",
463 "Query should be set to the selected task's command"
464 );
465 assert_eq!(
466 task_names(&tasks_picker, cx),
467 Vec::<String>::new(),
468 "No task should be listed"
469 );
470 cx.dispatch_action(picker::ConfirmInput { secondary: false });
471
472 let tasks_picker = open_spawn_tasks(&workspace, cx);
473 assert_eq!(
474 query(&tasks_picker, cx),
475 "",
476 "Query should be reset after confirming"
477 );
478 assert_eq!(
479 task_names(&tasks_picker, cx),
480 vec!["echo 4", "another one", "example task"],
481 "New oneshot task should be listed first"
482 );
483
484 let query_str = "echo 4";
485 cx.simulate_input(query_str);
486 assert_eq!(query(&tasks_picker, cx), query_str);
487 assert_eq!(
488 task_names(&tasks_picker, cx),
489 vec!["echo 4"],
490 "New oneshot should match custom command query"
491 );
492
493 cx.dispatch_action(picker::ConfirmInput { secondary: false });
494 let tasks_picker = open_spawn_tasks(&workspace, cx);
495 assert_eq!(
496 query(&tasks_picker, cx),
497 "",
498 "Query should be reset after confirming"
499 );
500 assert_eq!(
501 task_names(&tasks_picker, cx),
502 vec![query_str, "another one", "example task"],
503 "Last recently used one show task should be listed first"
504 );
505
506 cx.dispatch_action(picker::UseSelectedQuery);
507 assert_eq!(
508 query(&tasks_picker, cx),
509 query_str,
510 "Query should be set to the custom task's name"
511 );
512 assert_eq!(
513 task_names(&tasks_picker, cx),
514 vec![query_str],
515 "Only custom task should be listed"
516 );
517
518 let query_str = "0";
519 cx.simulate_input(query_str);
520 assert_eq!(query(&tasks_picker, cx), "echo 40");
521 assert_eq!(
522 task_names(&tasks_picker, cx),
523 Vec::<String>::new(),
524 "New oneshot should not match any command query"
525 );
526
527 cx.dispatch_action(picker::ConfirmInput { secondary: true });
528 let tasks_picker = open_spawn_tasks(&workspace, cx);
529 assert_eq!(
530 query(&tasks_picker, cx),
531 "",
532 "Query should be reset after confirming"
533 );
534 assert_eq!(
535 task_names(&tasks_picker, cx),
536 vec!["echo 4", "another one", "example task", "echo 40"],
537 "Last recently used one show task should be listed last, as it is a fire-and-forget task"
538 );
539 }
540
541 fn open_spawn_tasks(
542 workspace: &View<Workspace>,
543 cx: &mut VisualTestContext,
544 ) -> View<Picker<TasksModalDelegate>> {
545 cx.dispatch_action(crate::modal::Spawn::default());
546 workspace.update(cx, |workspace, cx| {
547 workspace
548 .active_modal::<TasksModal>(cx)
549 .unwrap()
550 .read(cx)
551 .picker
552 .clone()
553 })
554 }
555
556 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
557 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
558 }
559
560 fn task_names(
561 spawn_tasks: &View<Picker<TasksModalDelegate>>,
562 cx: &mut VisualTestContext,
563 ) -> Vec<String> {
564 spawn_tasks.update(cx, |spawn_tasks, _| {
565 spawn_tasks
566 .delegate
567 .matches
568 .iter()
569 .map(|hit| hit.string.clone())
570 .collect::<Vec<_>>()
571 })
572 }
573}