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