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