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        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}