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        let mut spawn_prompt = task.prepare_exec(self.task_context.clone())?;
370        if !spawn_prompt.args.is_empty() {
371            spawn_prompt.command.push(' ');
372            spawn_prompt
373                .command
374                .extend(intersperse(spawn_prompt.args, " ".to_string()));
375        }
376        Some(spawn_prompt.command)
377    }
378
379    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
380        let Some(task) = self.spawn_oneshot(cx) else {
381            return;
382        };
383        self.workspace
384            .update(cx, |workspace, cx| {
385                schedule_task(
386                    workspace,
387                    task.as_ref(),
388                    self.task_context.clone(),
389                    omit_history_entry,
390                    cx,
391                );
392            })
393            .ok();
394        cx.emit(DismissEvent);
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use gpui::{TestAppContext, VisualTestContext};
401    use project::{FakeFs, Project};
402    use serde_json::json;
403
404    use super::*;
405
406    #[gpui::test]
407    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
408        crate::tests::init_test(cx);
409        let fs = FakeFs::new(cx.executor());
410        fs.insert_tree(
411            "/dir",
412            json!({
413                ".zed": {
414                    "tasks.json": r#"[
415                        {
416                            "label": "example task",
417                            "command": "echo",
418                            "args": ["4"]
419                        },
420                        {
421                            "label": "another one",
422                            "command": "echo",
423                            "args": ["55"]
424                        },
425                    ]"#,
426                },
427                "a.ts": "a"
428            }),
429        )
430        .await;
431
432        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
433        project.update(cx, |project, cx| {
434            project.task_inventory().update(cx, |inventory, cx| {
435                inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
436            })
437        });
438
439        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
440
441        let tasks_picker = open_spawn_tasks(&workspace, cx);
442        assert_eq!(
443            query(&tasks_picker, cx),
444            "",
445            "Initial query should be empty"
446        );
447        assert_eq!(
448            task_names(&tasks_picker, cx),
449            vec!["another one", "example task"],
450            "Initial tasks should be listed in alphabetical order"
451        );
452
453        let query_str = "tas";
454        cx.simulate_input(query_str);
455        assert_eq!(query(&tasks_picker, cx), query_str);
456        assert_eq!(
457            task_names(&tasks_picker, cx),
458            vec!["example task"],
459            "Only one task should match the query {query_str}"
460        );
461
462        cx.dispatch_action(picker::UseSelectedQuery);
463        assert_eq!(
464            query(&tasks_picker, cx),
465            "echo 4",
466            "Query should be set to the selected task's command"
467        );
468        assert_eq!(
469            task_names(&tasks_picker, cx),
470            Vec::<String>::new(),
471            "No task should be listed"
472        );
473        cx.dispatch_action(picker::ConfirmInput { secondary: false });
474
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!["echo 4", "another one", "example task"],
484            "New oneshot task should be listed first"
485        );
486
487        let query_str = "echo 4";
488        cx.simulate_input(query_str);
489        assert_eq!(query(&tasks_picker, cx), query_str);
490        assert_eq!(
491            task_names(&tasks_picker, cx),
492            vec!["echo 4"],
493            "New oneshot should match custom command query"
494        );
495
496        cx.dispatch_action(picker::ConfirmInput { secondary: false });
497        let tasks_picker = open_spawn_tasks(&workspace, cx);
498        assert_eq!(
499            query(&tasks_picker, cx),
500            "",
501            "Query should be reset after confirming"
502        );
503        assert_eq!(
504            task_names(&tasks_picker, cx),
505            vec![query_str, "another one", "example task"],
506            "Last recently used one show task should be listed first"
507        );
508
509        cx.dispatch_action(picker::UseSelectedQuery);
510        assert_eq!(
511            query(&tasks_picker, cx),
512            query_str,
513            "Query should be set to the custom task's name"
514        );
515        assert_eq!(
516            task_names(&tasks_picker, cx),
517            vec![query_str],
518            "Only custom task should be listed"
519        );
520
521        let query_str = "0";
522        cx.simulate_input(query_str);
523        assert_eq!(query(&tasks_picker, cx), "echo 40");
524        assert_eq!(
525            task_names(&tasks_picker, cx),
526            Vec::<String>::new(),
527            "New oneshot should not match any command query"
528        );
529
530        cx.dispatch_action(picker::ConfirmInput { secondary: true });
531        let tasks_picker = open_spawn_tasks(&workspace, cx);
532        assert_eq!(
533            query(&tasks_picker, cx),
534            "",
535            "Query should be reset after confirming"
536        );
537        assert_eq!(
538            task_names(&tasks_picker, cx),
539            vec!["echo 4", "another one", "example task", "echo 40"],
540            "Last recently used one show task should be listed last, as it is a fire-and-forget task"
541        );
542    }
543
544    fn open_spawn_tasks(
545        workspace: &View<Workspace>,
546        cx: &mut VisualTestContext,
547    ) -> View<Picker<TasksModalDelegate>> {
548        cx.dispatch_action(crate::modal::Spawn::default());
549        workspace.update(cx, |workspace, cx| {
550            workspace
551                .active_modal::<TasksModal>(cx)
552                .unwrap()
553                .read(cx)
554                .picker
555                .clone()
556        })
557    }
558
559    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
560        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
561    }
562
563    fn task_names(
564        spawn_tasks: &View<Picker<TasksModalDelegate>>,
565        cx: &mut VisualTestContext,
566    ) -> Vec<String> {
567        spawn_tasks.update(cx, |spawn_tasks, _| {
568            spawn_tasks
569                .delegate
570                .matches
571                .iter()
572                .map(|hit| hit.string.clone())
573                .collect::<Vec<_>>()
574        })
575    }
576}