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