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    placeholder_text: Arc<str>,
 59}
 60
 61impl TasksModalDelegate {
 62    fn new(
 63        inventory: Model<Inventory>,
 64        task_context: TaskContext,
 65        workspace: WeakView<Workspace>,
 66    ) -> Self {
 67        Self {
 68            inventory,
 69            workspace,
 70            candidates: None,
 71            matches: Vec::new(),
 72            selected_index: 0,
 73            prompt: String::default(),
 74            task_context,
 75            placeholder_text: Arc::from("Run a task..."),
 76        }
 77    }
 78
 79    fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
 80        if self.prompt.trim().is_empty() {
 81            return None;
 82        }
 83
 84        self.inventory
 85            .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
 86            .update(cx, |oneshot_source, _| {
 87                Some(
 88                    oneshot_source
 89                        .as_any()
 90                        .downcast_mut::<OneshotSource>()?
 91                        .spawn(self.prompt.clone()),
 92                )
 93            })
 94    }
 95
 96    fn delete_oneshot(&mut self, ix: usize, cx: &mut AppContext) {
 97        let Some(candidates) = self.candidates.as_mut() else {
 98            return;
 99        };
100        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
101            return;
102        };
103        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
104        // 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
105        // the original list without a removed entry.
106        candidates.remove(ix);
107        self.inventory.update(cx, |inventory, cx| {
108            let oneshot_source = inventory.source::<OneshotSource>()?;
109            let task_id = task.id();
110
111            oneshot_source.update(cx, |this, _| {
112                let oneshot_source = this.as_any().downcast_mut::<OneshotSource>()?;
113                oneshot_source.remove(task_id);
114                Some(())
115            });
116            Some(())
117        });
118    }
119    fn active_item_path(
120        workspace: &WeakView<Workspace>,
121        cx: &mut ViewContext<'_, Picker<Self>>,
122    ) -> Option<(PathBuf, ProjectPath)> {
123        let workspace = workspace.upgrade()?.read(cx);
124        let project = workspace.project().read(cx);
125        let active_item = workspace.active_item(cx)?;
126        active_item.project_path(cx).and_then(|project_path| {
127            project
128                .worktree_for_id(project_path.worktree_id, cx)
129                .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
130                .zip(Some(project_path))
131        })
132    }
133}
134
135pub(crate) struct TasksModal {
136    picker: View<Picker<TasksModalDelegate>>,
137    _subscription: Subscription,
138}
139
140impl TasksModal {
141    pub(crate) fn new(
142        inventory: Model<Inventory>,
143        task_context: TaskContext,
144        workspace: WeakView<Workspace>,
145        cx: &mut ViewContext<Self>,
146    ) -> Self {
147        let picker = cx.new_view(|cx| {
148            Picker::uniform_list(
149                TasksModalDelegate::new(inventory, task_context, workspace),
150                cx,
151            )
152        });
153        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
154            cx.emit(DismissEvent);
155        });
156        Self {
157            picker,
158            _subscription,
159        }
160    }
161}
162
163impl Render for TasksModal {
164    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
165        v_flex()
166            .key_context("TasksModal")
167            .w(rems(34.))
168            .child(self.picker.clone())
169            .on_mouse_down_out(cx.listener(|modal, _, cx| {
170                modal.picker.update(cx, |picker, cx| {
171                    picker.cancel(&Default::default(), cx);
172                })
173            }))
174    }
175}
176
177impl EventEmitter<DismissEvent> for TasksModal {}
178
179impl FocusableView for TasksModal {
180    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
181        self.picker.read(cx).focus_handle(cx)
182    }
183}
184
185impl ModalView for TasksModal {}
186
187impl PickerDelegate for TasksModalDelegate {
188    type ListItem = ListItem;
189
190    fn match_count(&self) -> usize {
191        self.matches.len()
192    }
193
194    fn selected_index(&self) -> usize {
195        self.selected_index
196    }
197
198    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
199        self.selected_index = ix;
200    }
201
202    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
203        self.placeholder_text.clone()
204    }
205
206    fn update_matches(
207        &mut self,
208        query: String,
209        cx: &mut ViewContext<picker::Picker<Self>>,
210    ) -> gpui::Task<()> {
211        cx.spawn(move |picker, mut cx| async move {
212            let Some(candidates) = picker
213                .update(&mut cx, |picker, cx| {
214                    let candidates = picker.delegate.candidates.get_or_insert_with(|| {
215                        let (path, worktree) =
216                            match Self::active_item_path(&picker.delegate.workspace, cx) {
217                                Some((abs_path, project_path)) => {
218                                    (Some(abs_path), Some(project_path.worktree_id))
219                                }
220                                None => (None, None),
221                            };
222                        picker.delegate.inventory.update(cx, |inventory, cx| {
223                            inventory.list_tasks(path.as_deref(), worktree, true, cx)
224                        })
225                    });
226
227                    candidates
228                        .iter()
229                        .enumerate()
230                        .map(|(index, (_, candidate))| StringMatchCandidate {
231                            id: index,
232                            char_bag: candidate.name().chars().collect(),
233                            string: candidate.name().into(),
234                        })
235                        .collect::<Vec<_>>()
236                })
237                .ok()
238            else {
239                return;
240            };
241            let matches = fuzzy::match_strings(
242                &candidates,
243                &query,
244                true,
245                1000,
246                &Default::default(),
247                cx.background_executor().clone(),
248            )
249            .await;
250            picker
251                .update(&mut cx, |picker, _| {
252                    let delegate = &mut picker.delegate;
253                    delegate.matches = matches;
254                    delegate.prompt = query;
255
256                    if delegate.matches.is_empty() {
257                        delegate.selected_index = 0;
258                    } else {
259                        delegate.selected_index =
260                            delegate.selected_index.min(delegate.matches.len() - 1);
261                    }
262                })
263                .log_err();
264        })
265    }
266
267    fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
268        let current_match_index = self.selected_index();
269        let task = self
270            .matches
271            .get(current_match_index)
272            .and_then(|current_match| {
273                let ix = current_match.candidate_id;
274                self.candidates
275                    .as_ref()
276                    .map(|candidates| candidates[ix].1.clone())
277            });
278        let Some(task) = task else {
279            return;
280        };
281
282        self.workspace
283            .update(cx, |workspace, cx| {
284                schedule_task(
285                    workspace,
286                    task.as_ref(),
287                    self.task_context.clone(),
288                    omit_history_entry,
289                    cx,
290                );
291            })
292            .ok();
293        cx.emit(DismissEvent);
294    }
295
296    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
297        cx.emit(DismissEvent);
298    }
299
300    fn render_match(
301        &self,
302        ix: usize,
303        selected: bool,
304        cx: &mut ViewContext<picker::Picker<Self>>,
305    ) -> Option<Self::ListItem> {
306        let candidates = self.candidates.as_ref()?;
307        let hit = &self.matches.get(ix)?;
308        let (source_kind, _) = &candidates.get(hit.candidate_id)?;
309        let details = match source_kind {
310            TaskSourceKind::UserInput => "user input".to_string(),
311            TaskSourceKind::Buffer => "language extension".to_string(),
312            TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
313                abs_path.compact().to_string_lossy().to_string()
314            }
315        };
316
317        let highlighted_location = HighlightedMatchWithPaths {
318            match_label: HighlightedText {
319                text: hit.string.clone(),
320                highlight_positions: hit.positions.clone(),
321                char_count: hit.string.chars().count(),
322            },
323            paths: vec![HighlightedText {
324                char_count: details.chars().count(),
325                highlight_positions: Vec::new(),
326                text: details,
327            }],
328        };
329        Some(
330            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
331                .inset(true)
332                .spacing(ListItemSpacing::Sparse)
333                .map(|this| {
334                    if matches!(source_kind, TaskSourceKind::UserInput) {
335                        let task_index = hit.candidate_id;
336                        let delete_button = div().child(
337                            IconButton::new("delete", IconName::Close)
338                                .shape(IconButtonShape::Square)
339                                .icon_color(Color::Muted)
340                                .size(ButtonSize::None)
341                                .icon_size(IconSize::XSmall)
342                                .on_click(cx.listener(move |this, _event, cx| {
343                                    cx.stop_propagation();
344                                    cx.prevent_default();
345
346                                    this.delegate.delete_oneshot(task_index, cx);
347                                    this.refresh(cx);
348                                }))
349                                .tooltip(|cx| Tooltip::text("Delete an one-shot task", cx)),
350                        );
351                        this.end_hover_slot(delete_button)
352                    } else {
353                        this
354                    }
355                })
356                .selected(selected)
357                .child(highlighted_location.render(cx)),
358        )
359    }
360
361    fn selected_as_query(&self) -> Option<String> {
362        use itertools::intersperse;
363        let task_index = self.matches.get(self.selected_index())?.candidate_id;
364        let tasks = self.candidates.as_ref()?;
365        let (_, task) = tasks.get(task_index)?;
366        let mut spawn_prompt = task.prepare_exec(self.task_context.clone())?;
367        if !spawn_prompt.args.is_empty() {
368            spawn_prompt.command.push(' ');
369            spawn_prompt
370                .command
371                .extend(intersperse(spawn_prompt.args, " ".to_string()));
372        }
373        Some(spawn_prompt.command)
374    }
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}