modal.rs

  1use std::sync::Arc;
  2
  3use crate::{active_item_selection_properties, schedule_resolved_task};
  4use fuzzy::{StringMatch, StringMatchCandidate};
  5use gpui::{
  6    impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, Global,
  7    InteractiveElement, Model, ParentElement, Render, SharedString, Styled, Subscription, View,
  8    ViewContext, VisualContext, WeakView,
  9};
 10use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate};
 11use project::{Inventory, TaskSourceKind};
 12use task::{ResolvedTask, TaskContext, TaskTemplate};
 13use ui::{
 14    div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton,
 15    IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable,
 16    Tooltip, WindowContext,
 17};
 18use util::ResultExt;
 19use workspace::{ModalView, Workspace};
 20
 21use serde::Deserialize;
 22
 23/// Spawn a task with name or open tasks modal
 24#[derive(PartialEq, Clone, Deserialize, Default)]
 25pub struct Spawn {
 26    #[serde(default)]
 27    /// Name of the task to spawn.
 28    /// If it is not set, a modal with a list of available tasks is opened instead.
 29    /// Defaults to None.
 30    pub task_name: Option<String>,
 31}
 32
 33impl Spawn {
 34    pub(crate) fn modal() -> Self {
 35        Self { task_name: None }
 36    }
 37}
 38/// Rerun last task
 39#[derive(PartialEq, Clone, Deserialize, Default)]
 40pub struct Rerun {
 41    #[serde(default)]
 42    /// Controls whether the task context is reevaluated prior to execution of a task.
 43    /// 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
 44    /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
 45    /// default: false
 46    pub reevaluate_context: bool,
 47}
 48
 49impl_actions!(task, [Rerun, Spawn]);
 50
 51/// A modal used to spawn new tasks.
 52pub(crate) struct TasksModalDelegate {
 53    inventory: Model<Inventory>,
 54    candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
 55    last_used_candidate_index: Option<usize>,
 56    matches: Vec<StringMatch>,
 57    selected_index: usize,
 58    workspace: WeakView<Workspace>,
 59    prompt: String,
 60    task_context: TaskContext,
 61    placeholder_text: Arc<str>,
 62}
 63
 64impl TasksModalDelegate {
 65    fn new(
 66        inventory: Model<Inventory>,
 67        task_context: TaskContext,
 68        workspace: WeakView<Workspace>,
 69    ) -> Self {
 70        Self {
 71            inventory,
 72            workspace,
 73            candidates: None,
 74            matches: Vec::new(),
 75            last_used_candidate_index: None,
 76            selected_index: 0,
 77            prompt: String::default(),
 78            task_context,
 79            placeholder_text: Arc::from("Run a task..."),
 80        }
 81    }
 82
 83    fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
 84        if self.prompt.trim().is_empty() {
 85            return None;
 86        }
 87
 88        let source_kind = TaskSourceKind::UserInput;
 89        let id_base = source_kind.to_id_base();
 90        let new_oneshot = TaskTemplate {
 91            label: self.prompt.clone(),
 92            command: self.prompt.clone(),
 93            ..TaskTemplate::default()
 94        };
 95        Some((
 96            source_kind,
 97            new_oneshot.resolve_task(&id_base, self.task_context.clone())?,
 98        ))
 99    }
100
101    fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
102        let Some(candidates) = self.candidates.as_mut() else {
103            return;
104        };
105        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
106            return;
107        };
108        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
109        // 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
110        // the original list without a removed entry.
111        candidates.remove(ix);
112        self.inventory.update(cx, |inventory, _| {
113            inventory.delete_previously_used(&task.id);
114        });
115    }
116}
117
118pub(crate) struct TasksModal {
119    picker: View<Picker<TasksModalDelegate>>,
120    _subscription: Subscription,
121}
122
123impl TasksModal {
124    pub(crate) fn new(
125        inventory: Model<Inventory>,
126        task_context: TaskContext,
127        workspace: WeakView<Workspace>,
128        cx: &mut ViewContext<Self>,
129    ) -> Self {
130        let picker = cx.new_view(|cx| {
131            Picker::uniform_list(
132                TasksModalDelegate::new(inventory, task_context, workspace),
133                cx,
134            )
135        });
136        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
137            cx.emit(DismissEvent);
138        });
139        Self {
140            picker,
141            _subscription,
142        }
143    }
144}
145
146impl Render for TasksModal {
147    fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
148        v_flex()
149            .key_context("TasksModal")
150            .w(rems(34.))
151            .child(self.picker.clone())
152    }
153}
154
155impl EventEmitter<DismissEvent> for TasksModal {}
156
157impl FocusableView for TasksModal {
158    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
159        self.picker.read(cx).focus_handle(cx)
160    }
161}
162
163impl ModalView for TasksModal {}
164
165impl PickerDelegate for TasksModalDelegate {
166    type ListItem = ListItem;
167
168    fn match_count(&self) -> usize {
169        self.matches.len()
170    }
171
172    fn selected_index(&self) -> usize {
173        self.selected_index
174    }
175
176    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
177        self.selected_index = ix;
178    }
179
180    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
181        self.placeholder_text.clone()
182    }
183
184    fn update_matches(
185        &mut self,
186        query: String,
187        cx: &mut ViewContext<picker::Picker<Self>>,
188    ) -> gpui::Task<()> {
189        cx.spawn(move |picker, mut cx| async move {
190            let Some(candidates) = picker
191                .update(&mut cx, |picker, cx| {
192                    let candidates = match &mut picker.delegate.candidates {
193                        Some(candidates) => candidates,
194                        None => {
195                            let Ok((worktree, language)) =
196                                picker.delegate.workspace.update(cx, |workspace, cx| {
197                                    active_item_selection_properties(workspace, cx)
198                                })
199                            else {
200                                return Vec::new();
201                            };
202                            let (used, current) =
203                                picker.delegate.inventory.update(cx, |inventory, cx| {
204                                    inventory.used_and_current_resolved_tasks(
205                                        language,
206                                        worktree,
207                                        picker.delegate.task_context.clone(),
208                                        cx,
209                                    )
210                                });
211                            picker.delegate.last_used_candidate_index = if used.is_empty() {
212                                None
213                            } else {
214                                Some(used.len() - 1)
215                            };
216
217                            let mut new_candidates = used;
218                            new_candidates.extend(current);
219                            picker.delegate.candidates.insert(new_candidates)
220                        }
221                    };
222                    candidates
223                        .iter()
224                        .enumerate()
225                        .map(|(index, (_, candidate))| StringMatchCandidate {
226                            id: index,
227                            char_bag: candidate.resolved_label.chars().collect(),
228                            string: candidate
229                                .resolved
230                                .as_ref()
231                                .map(|resolved| resolved.label.clone())
232                                .unwrap_or_else(|| candidate.resolved_label.clone()),
233                        })
234                        .collect::<Vec<_>>()
235                })
236                .ok()
237            else {
238                return;
239            };
240            let matches = fuzzy::match_strings(
241                &candidates,
242                &query,
243                true,
244                1000,
245                &Default::default(),
246                cx.background_executor().clone(),
247            )
248            .await;
249            picker
250                .update(&mut cx, |picker, _| {
251                    let delegate = &mut picker.delegate;
252                    delegate.matches = matches;
253                    delegate.prompt = query;
254
255                    if delegate.matches.is_empty() {
256                        delegate.selected_index = 0;
257                    } else {
258                        delegate.selected_index =
259                            delegate.selected_index.min(delegate.matches.len() - 1);
260                    }
261                })
262                .log_err();
263        })
264    }
265
266    fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
267        let current_match_index = self.selected_index();
268        let task = self
269            .matches
270            .get(current_match_index)
271            .and_then(|current_match| {
272                let ix = current_match.candidate_id;
273                self.candidates
274                    .as_ref()
275                    .map(|candidates| candidates[ix].clone())
276            });
277        let Some((task_source_kind, task)) = task else {
278            return;
279        };
280
281        self.workspace
282            .update(cx, |workspace, cx| {
283                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
284            })
285            .ok();
286        cx.emit(DismissEvent);
287    }
288
289    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
290        cx.emit(DismissEvent);
291    }
292
293    fn render_match(
294        &self,
295        ix: usize,
296        selected: bool,
297        cx: &mut ViewContext<picker::Picker<Self>>,
298    ) -> Option<Self::ListItem> {
299        let candidates = self.candidates.as_ref()?;
300        let hit = &self.matches[ix];
301        let (source_kind, _) = &candidates.get(hit.candidate_id)?;
302
303        let highlighted_location = HighlightedText {
304            text: hit.string.clone(),
305            highlight_positions: hit.positions.clone(),
306            char_count: hit.string.chars().count(),
307        };
308        let icon = match source_kind {
309            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
310            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
311            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
312            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
313                .get_type_icon(&name.to_lowercase())
314                .map(|icon_path| Icon::from_path(icon_path)),
315        };
316        Some(
317            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
318                .inset(true)
319                .spacing(ListItemSpacing::Sparse)
320                .map(|item| {
321                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
322                        || Some(ix) <= self.last_used_candidate_index
323                    {
324                        let task_index = hit.candidate_id;
325                        let delete_button = div().child(
326                            IconButton::new("delete", IconName::Close)
327                                .shape(IconButtonShape::Square)
328                                .icon_color(Color::Muted)
329                                .size(ButtonSize::None)
330                                .icon_size(IconSize::XSmall)
331                                .on_click(cx.listener(move |picker, _event, cx| {
332                                    cx.stop_propagation();
333                                    cx.prevent_default();
334
335                                    picker.delegate.delete_previously_used(task_index, cx);
336                                    picker.delegate.last_used_candidate_index = picker
337                                        .delegate
338                                        .last_used_candidate_index
339                                        .unwrap_or(0)
340                                        .checked_sub(1);
341                                    picker.refresh(cx);
342                                }))
343                                .tooltip(|cx| {
344                                    Tooltip::text("Delete previously scheduled task", cx)
345                                }),
346                        );
347                        item.end_hover_slot(delete_button)
348                    } else {
349                        item
350                    };
351                    if let Some(icon) = icon {
352                        item.end_slot(icon)
353                    } else {
354                        item
355                    }
356                })
357                .selected(selected)
358                .child(highlighted_location.render(cx)),
359        )
360    }
361
362    fn selected_as_query(&self) -> Option<String> {
363        use itertools::intersperse;
364        let task_index = self.matches.get(self.selected_index())?.candidate_id;
365        let tasks = self.candidates.as_ref()?;
366        let (_, task) = tasks.get(task_index)?;
367        task.resolved.as_ref().map(|spawn_in_terminal| {
368            let mut command = spawn_in_terminal.command.clone();
369            if !spawn_in_terminal.args.is_empty() {
370                command.push(' ');
371                command.extend(intersperse(spawn_in_terminal.args.clone(), " ".to_string()));
372            }
373            command
374        })
375    }
376
377    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
378        let Some((task_source_kind, task)) = self.spawn_oneshot() else {
379            return;
380        };
381        self.workspace
382            .update(cx, |workspace, cx| {
383                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
384            })
385            .ok();
386        cx.emit(DismissEvent);
387    }
388
389    fn separators_after_indices(&self) -> Vec<usize> {
390        if let Some(i) = self.last_used_candidate_index {
391            vec![i]
392        } else {
393            Vec::new()
394        }
395    }
396}
397
398// TODO kb more tests on recent tasks from language templates
399#[cfg(test)]
400mod tests {
401    use gpui::{TestAppContext, VisualTestContext};
402    use project::{FakeFs, Project};
403    use serde_json::json;
404
405    use crate::modal::Spawn;
406
407    use super::*;
408
409    #[gpui::test]
410    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
411        crate::tests::init_test(cx);
412        let fs = FakeFs::new(cx.executor());
413        fs.insert_tree(
414            "/dir",
415            json!({
416                ".zed": {
417                    "tasks.json": r#"[
418                        {
419                            "label": "example task",
420                            "command": "echo",
421                            "args": ["4"]
422                        },
423                        {
424                            "label": "another one",
425                            "command": "echo",
426                            "args": ["55"]
427                        },
428                    ]"#,
429                },
430                "a.ts": "a"
431            }),
432        )
433        .await;
434
435        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
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"],
537            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
538        );
539
540        cx.dispatch_action(Spawn {
541            task_name: Some("example task".to_string()),
542        });
543        let tasks_picker = workspace.update(cx, |workspace, cx| {
544            workspace
545                .active_modal::<TasksModal>(cx)
546                .unwrap()
547                .read(cx)
548                .picker
549                .clone()
550        });
551        assert_eq!(
552            task_names(&tasks_picker, cx),
553            vec!["echo 4", "another one", "example task"],
554        );
555    }
556
557    fn open_spawn_tasks(
558        workspace: &View<Workspace>,
559        cx: &mut VisualTestContext,
560    ) -> View<Picker<TasksModalDelegate>> {
561        cx.dispatch_action(Spawn::default());
562        workspace.update(cx, |workspace, cx| {
563            workspace
564                .active_modal::<TasksModal>(cx)
565                .unwrap()
566                .read(cx)
567                .picker
568                .clone()
569        })
570    }
571
572    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
573        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
574    }
575
576    fn task_names(
577        spawn_tasks: &View<Picker<TasksModalDelegate>>,
578        cx: &mut VisualTestContext,
579    ) -> Vec<String> {
580        spawn_tasks.update(cx, |spawn_tasks, _| {
581            spawn_tasks
582                .delegate
583                .matches
584                .iter()
585                .map(|hit| hit.string.clone())
586                .collect::<Vec<_>>()
587        })
588    }
589}