modal.rs

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