modal.rs

  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}