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