modal.rs

  1use std::sync::Arc;
  2
  3use crate::{active_item_selection_properties, schedule_task};
  4use fuzzy::{StringMatch, StringMatchCandidate};
  5use gpui::{
  6    impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
  7    Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
  8    VisualContext, WeakView,
  9};
 10use picker::{
 11    highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
 12    Picker, PickerDelegate,
 13};
 14use project::{Inventory, TaskSourceKind};
 15use task::{oneshot_source::OneshotSource, Task, TaskContext};
 16use ui::{
 17    div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, IconButton,
 18    IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable,
 19    Tooltip, WindowContext,
 20};
 21use util::{paths::PathExt, ResultExt};
 22use workspace::{ModalView, Workspace};
 23
 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}
120
121pub(crate) struct TasksModal {
122    picker: View<Picker<TasksModalDelegate>>,
123    _subscription: Subscription,
124}
125
126impl TasksModal {
127    pub(crate) fn new(
128        inventory: Model<Inventory>,
129        task_context: TaskContext,
130        workspace: WeakView<Workspace>,
131        cx: &mut ViewContext<Self>,
132    ) -> Self {
133        let picker = cx.new_view(|cx| {
134            Picker::uniform_list(
135                TasksModalDelegate::new(inventory, task_context, workspace),
136                cx,
137            )
138        });
139        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
140            cx.emit(DismissEvent);
141        });
142        Self {
143            picker,
144            _subscription,
145        }
146    }
147}
148
149impl Render for TasksModal {
150    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
151        v_flex()
152            .key_context("TasksModal")
153            .w(rems(34.))
154            .child(self.picker.clone())
155            .on_mouse_down_out(cx.listener(|modal, _, cx| {
156                modal.picker.update(cx, |picker, cx| {
157                    picker.cancel(&Default::default(), cx);
158                })
159            }))
160    }
161}
162
163impl EventEmitter<DismissEvent> for TasksModal {}
164
165impl FocusableView for TasksModal {
166    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
167        self.picker.read(cx).focus_handle(cx)
168    }
169}
170
171impl ModalView for TasksModal {}
172
173impl PickerDelegate for TasksModalDelegate {
174    type ListItem = ListItem;
175
176    fn match_count(&self) -> usize {
177        self.matches.len()
178    }
179
180    fn selected_index(&self) -> usize {
181        self.selected_index
182    }
183
184    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
185        self.selected_index = ix;
186    }
187
188    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
189        self.placeholder_text.clone()
190    }
191
192    fn update_matches(
193        &mut self,
194        query: String,
195        cx: &mut ViewContext<picker::Picker<Self>>,
196    ) -> gpui::Task<()> {
197        cx.spawn(move |picker, mut cx| async move {
198            let Some(candidates) = picker
199                .update(&mut cx, |picker, cx| {
200                    let candidates = picker.delegate.candidates.get_or_insert_with(|| {
201                        let (worktree, language) =
202                            active_item_selection_properties(&picker.delegate.workspace, cx);
203                        picker.delegate.inventory.update(cx, |inventory, cx| {
204                            inventory.list_tasks(language, worktree, true, cx)
205                        })
206                    });
207
208                    candidates
209                        .iter()
210                        .enumerate()
211                        .map(|(index, (_, candidate))| StringMatchCandidate {
212                            id: index,
213                            char_bag: candidate.name().chars().collect(),
214                            string: candidate.name().into(),
215                        })
216                        .collect::<Vec<_>>()
217                })
218                .ok()
219            else {
220                return;
221            };
222            let matches = fuzzy::match_strings(
223                &candidates,
224                &query,
225                true,
226                1000,
227                &Default::default(),
228                cx.background_executor().clone(),
229            )
230            .await;
231            picker
232                .update(&mut cx, |picker, _| {
233                    let delegate = &mut picker.delegate;
234                    delegate.matches = matches;
235                    delegate.prompt = query;
236
237                    if delegate.matches.is_empty() {
238                        delegate.selected_index = 0;
239                    } else {
240                        delegate.selected_index =
241                            delegate.selected_index.min(delegate.matches.len() - 1);
242                    }
243                })
244                .log_err();
245        })
246    }
247
248    fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
249        let current_match_index = self.selected_index();
250        let task = self
251            .matches
252            .get(current_match_index)
253            .and_then(|current_match| {
254                let ix = current_match.candidate_id;
255                self.candidates
256                    .as_ref()
257                    .map(|candidates| candidates[ix].1.clone())
258            });
259        let Some(task) = task else {
260            return;
261        };
262
263        self.workspace
264            .update(cx, |workspace, cx| {
265                schedule_task(
266                    workspace,
267                    &task,
268                    self.task_context.clone(),
269                    omit_history_entry,
270                    cx,
271                );
272            })
273            .ok();
274        cx.emit(DismissEvent);
275    }
276
277    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
278        cx.emit(DismissEvent);
279    }
280
281    fn render_match(
282        &self,
283        ix: usize,
284        selected: bool,
285        cx: &mut ViewContext<picker::Picker<Self>>,
286    ) -> Option<Self::ListItem> {
287        let candidates = self.candidates.as_ref()?;
288        let hit = &self.matches.get(ix)?;
289        let (source_kind, _) = &candidates.get(hit.candidate_id)?;
290        let details = match source_kind {
291            TaskSourceKind::UserInput => "user input".to_string(),
292            TaskSourceKind::Language { name } => format!("{name} language"),
293            TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
294                abs_path.compact().to_string_lossy().to_string()
295            }
296        };
297
298        let highlighted_location = HighlightedMatchWithPaths {
299            match_label: HighlightedText {
300                text: hit.string.clone(),
301                highlight_positions: hit.positions.clone(),
302                char_count: hit.string.chars().count(),
303            },
304            paths: vec![HighlightedText {
305                char_count: details.chars().count(),
306                highlight_positions: Vec::new(),
307                text: details,
308            }],
309        };
310        Some(
311            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
312                .inset(true)
313                .spacing(ListItemSpacing::Sparse)
314                .map(|this| {
315                    if matches!(source_kind, TaskSourceKind::UserInput) {
316                        let task_index = hit.candidate_id;
317                        let delete_button = div().child(
318                            IconButton::new("delete", IconName::Close)
319                                .shape(IconButtonShape::Square)
320                                .icon_color(Color::Muted)
321                                .size(ButtonSize::None)
322                                .icon_size(IconSize::XSmall)
323                                .on_click(cx.listener(move |this, _event, cx| {
324                                    cx.stop_propagation();
325                                    cx.prevent_default();
326
327                                    this.delegate.delete_oneshot(task_index, cx);
328                                    this.refresh(cx);
329                                }))
330                                .tooltip(|cx| Tooltip::text("Delete an one-shot task", cx)),
331                        );
332                        this.end_hover_slot(delete_button)
333                    } else {
334                        this
335                    }
336                })
337                .selected(selected)
338                .child(highlighted_location.render(cx)),
339        )
340    }
341
342    fn selected_as_query(&self) -> Option<String> {
343        use itertools::intersperse;
344        let task_index = self.matches.get(self.selected_index())?.candidate_id;
345        let tasks = self.candidates.as_ref()?;
346        let (_, task) = tasks.get(task_index)?;
347        let mut spawn_prompt = task.prepare_exec(self.task_context.clone())?;
348        if !spawn_prompt.args.is_empty() {
349            spawn_prompt.command.push(' ');
350            spawn_prompt
351                .command
352                .extend(intersperse(spawn_prompt.args, " ".to_string()));
353        }
354        Some(spawn_prompt.command)
355    }
356
357    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
358        let Some(task) = self.spawn_oneshot(cx) else {
359            return;
360        };
361        self.workspace
362            .update(cx, |workspace, cx| {
363                schedule_task(
364                    workspace,
365                    &task,
366                    self.task_context.clone(),
367                    omit_history_entry,
368                    cx,
369                );
370            })
371            .ok();
372        cx.emit(DismissEvent);
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use gpui::{TestAppContext, VisualTestContext};
379    use project::{FakeFs, Project};
380    use serde_json::json;
381
382    use super::*;
383
384    #[gpui::test]
385    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
386        crate::tests::init_test(cx);
387        let fs = FakeFs::new(cx.executor());
388        fs.insert_tree(
389            "/dir",
390            json!({
391                ".zed": {
392                    "tasks.json": r#"[
393                        {
394                            "label": "example task",
395                            "command": "echo",
396                            "args": ["4"]
397                        },
398                        {
399                            "label": "another one",
400                            "command": "echo",
401                            "args": ["55"]
402                        },
403                    ]"#,
404                },
405                "a.ts": "a"
406            }),
407        )
408        .await;
409
410        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
411        project.update(cx, |project, cx| {
412            project.task_inventory().update(cx, |inventory, cx| {
413                inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
414            })
415        });
416
417        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
418
419        let tasks_picker = open_spawn_tasks(&workspace, cx);
420        assert_eq!(
421            query(&tasks_picker, cx),
422            "",
423            "Initial query should be empty"
424        );
425        assert_eq!(
426            task_names(&tasks_picker, cx),
427            vec!["another one", "example task"],
428            "Initial tasks should be listed in alphabetical order"
429        );
430
431        let query_str = "tas";
432        cx.simulate_input(query_str);
433        assert_eq!(query(&tasks_picker, cx), query_str);
434        assert_eq!(
435            task_names(&tasks_picker, cx),
436            vec!["example task"],
437            "Only one task should match the query {query_str}"
438        );
439
440        cx.dispatch_action(picker::UseSelectedQuery);
441        assert_eq!(
442            query(&tasks_picker, cx),
443            "echo 4",
444            "Query should be set to the selected task's command"
445        );
446        assert_eq!(
447            task_names(&tasks_picker, cx),
448            Vec::<String>::new(),
449            "No task should be listed"
450        );
451        cx.dispatch_action(picker::ConfirmInput { secondary: false });
452
453        let tasks_picker = open_spawn_tasks(&workspace, cx);
454        assert_eq!(
455            query(&tasks_picker, cx),
456            "",
457            "Query should be reset after confirming"
458        );
459        assert_eq!(
460            task_names(&tasks_picker, cx),
461            vec!["echo 4", "another one", "example task"],
462            "New oneshot task should be listed first"
463        );
464
465        let query_str = "echo 4";
466        cx.simulate_input(query_str);
467        assert_eq!(query(&tasks_picker, cx), query_str);
468        assert_eq!(
469            task_names(&tasks_picker, cx),
470            vec!["echo 4"],
471            "New oneshot should match custom command query"
472        );
473
474        cx.dispatch_action(picker::ConfirmInput { secondary: false });
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![query_str, "another one", "example task"],
484            "Last recently used one show task should be listed first"
485        );
486
487        cx.dispatch_action(picker::UseSelectedQuery);
488        assert_eq!(
489            query(&tasks_picker, cx),
490            query_str,
491            "Query should be set to the custom task's name"
492        );
493        assert_eq!(
494            task_names(&tasks_picker, cx),
495            vec![query_str],
496            "Only custom task should be listed"
497        );
498
499        let query_str = "0";
500        cx.simulate_input(query_str);
501        assert_eq!(query(&tasks_picker, cx), "echo 40");
502        assert_eq!(
503            task_names(&tasks_picker, cx),
504            Vec::<String>::new(),
505            "New oneshot should not match any command query"
506        );
507
508        cx.dispatch_action(picker::ConfirmInput { secondary: true });
509        let tasks_picker = open_spawn_tasks(&workspace, cx);
510        assert_eq!(
511            query(&tasks_picker, cx),
512            "",
513            "Query should be reset after confirming"
514        );
515        assert_eq!(
516            task_names(&tasks_picker, cx),
517            vec!["echo 4", "another one", "example task", "echo 40"],
518            "Last recently used one show task should be listed last, as it is a fire-and-forget task"
519        );
520    }
521
522    fn open_spawn_tasks(
523        workspace: &View<Workspace>,
524        cx: &mut VisualTestContext,
525    ) -> View<Picker<TasksModalDelegate>> {
526        cx.dispatch_action(crate::modal::Spawn::default());
527        workspace.update(cx, |workspace, cx| {
528            workspace
529                .active_modal::<TasksModal>(cx)
530                .unwrap()
531                .read(cx)
532                .picker
533                .clone()
534        })
535    }
536
537    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
538        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
539    }
540
541    fn task_names(
542        spawn_tasks: &View<Picker<TasksModalDelegate>>,
543        cx: &mut VisualTestContext,
544    ) -> Vec<String> {
545        spawn_tasks.update(cx, |spawn_tasks, _| {
546            spawn_tasks
547                .delegate
548                .matches
549                .iter()
550                .map(|hit| hit.string.clone())
551                .collect::<Vec<_>>()
552        })
553    }
554}