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    /// Controls whether the task context is reevaluated prior to execution of a task.
 42    /// 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
 43    /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
 44    /// default: false
 45    #[serde(default)]
 46    pub reevaluate_context: bool,
 47    /// Overrides `allow_concurrent_runs` property of the task being reran.
 48    /// Default: null
 49    #[serde(default)]
 50    pub allow_concurrent_runs: Option<bool>,
 51    /// Overrides `use_new_terminal` property of the task being reran.
 52    /// Default: null
 53    #[serde(default)]
 54    pub use_new_terminal: Option<bool>,
 55}
 56
 57impl_actions!(task, [Rerun, Spawn]);
 58
 59/// A modal used to spawn new tasks.
 60pub(crate) struct TasksModalDelegate {
 61    inventory: Model<Inventory>,
 62    candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
 63    last_used_candidate_index: Option<usize>,
 64    matches: Vec<StringMatch>,
 65    selected_index: usize,
 66    workspace: WeakView<Workspace>,
 67    prompt: String,
 68    task_context: TaskContext,
 69    placeholder_text: Arc<str>,
 70}
 71
 72impl TasksModalDelegate {
 73    fn new(
 74        inventory: Model<Inventory>,
 75        task_context: TaskContext,
 76        workspace: WeakView<Workspace>,
 77    ) -> Self {
 78        Self {
 79            inventory,
 80            workspace,
 81            candidates: None,
 82            matches: Vec::new(),
 83            last_used_candidate_index: None,
 84            selected_index: 0,
 85            prompt: String::default(),
 86            task_context,
 87            placeholder_text: Arc::from("Run a task..."),
 88        }
 89    }
 90
 91    fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
 92        if self.prompt.trim().is_empty() {
 93            return None;
 94        }
 95
 96        let source_kind = TaskSourceKind::UserInput;
 97        let id_base = source_kind.to_id_base();
 98        let new_oneshot = TaskTemplate {
 99            label: self.prompt.clone(),
100            command: self.prompt.clone(),
101            ..TaskTemplate::default()
102        };
103        Some((
104            source_kind,
105            new_oneshot.resolve_task(&id_base, self.task_context.clone())?,
106        ))
107    }
108
109    fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
110        let Some(candidates) = self.candidates.as_mut() else {
111            return;
112        };
113        let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
114            return;
115        };
116        // We remove this candidate manually instead of .taking() the candidates, as we already know the index;
117        // 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
118        // the original list without a removed entry.
119        candidates.remove(ix);
120        self.inventory.update(cx, |inventory, _| {
121            inventory.delete_previously_used(&task.id);
122        });
123    }
124}
125
126pub(crate) struct TasksModal {
127    picker: View<Picker<TasksModalDelegate>>,
128    _subscription: Subscription,
129}
130
131impl TasksModal {
132    pub(crate) fn new(
133        inventory: Model<Inventory>,
134        task_context: TaskContext,
135        workspace: WeakView<Workspace>,
136        cx: &mut ViewContext<Self>,
137    ) -> Self {
138        let picker = cx.new_view(|cx| {
139            Picker::uniform_list(
140                TasksModalDelegate::new(inventory, task_context, workspace),
141                cx,
142            )
143        });
144        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
145            cx.emit(DismissEvent);
146        });
147        Self {
148            picker,
149            _subscription,
150        }
151    }
152}
153
154impl Render for TasksModal {
155    fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
156        v_flex()
157            .key_context("TasksModal")
158            .w(rems(34.))
159            .child(self.picker.clone())
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 = match &mut picker.delegate.candidates {
201                        Some(candidates) => candidates,
202                        None => {
203                            let Ok((worktree, language)) =
204                                picker.delegate.workspace.update(cx, |workspace, cx| {
205                                    active_item_selection_properties(workspace, cx)
206                                })
207                            else {
208                                return Vec::new();
209                            };
210                            let (used, current) =
211                                picker.delegate.inventory.update(cx, |inventory, cx| {
212                                    inventory.used_and_current_resolved_tasks(
213                                        language,
214                                        worktree,
215                                        picker.delegate.task_context.clone(),
216                                        cx,
217                                    )
218                                });
219                            picker.delegate.last_used_candidate_index = if used.is_empty() {
220                                None
221                            } else {
222                                Some(used.len() - 1)
223                            };
224
225                            let mut new_candidates = used;
226                            new_candidates.extend(current);
227                            picker.delegate.candidates.insert(new_candidates)
228                        }
229                    };
230                    candidates
231                        .iter()
232                        .enumerate()
233                        .map(|(index, (_, candidate))| StringMatchCandidate {
234                            id: index,
235                            char_bag: candidate.resolved_label.chars().collect(),
236                            string: candidate
237                                .resolved
238                                .as_ref()
239                                .map(|resolved| resolved.label.clone())
240                                .unwrap_or_else(|| candidate.resolved_label.clone()),
241                        })
242                        .collect::<Vec<_>>()
243                })
244                .ok()
245            else {
246                return;
247            };
248            let matches = fuzzy::match_strings(
249                &candidates,
250                &query,
251                true,
252                1000,
253                &Default::default(),
254                cx.background_executor().clone(),
255            )
256            .await;
257            picker
258                .update(&mut cx, |picker, _| {
259                    let delegate = &mut picker.delegate;
260                    delegate.matches = matches;
261                    delegate.prompt = query;
262
263                    if delegate.matches.is_empty() {
264                        delegate.selected_index = 0;
265                    } else {
266                        delegate.selected_index =
267                            delegate.selected_index.min(delegate.matches.len() - 1);
268                    }
269                })
270                .log_err();
271        })
272    }
273
274    fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
275        let current_match_index = self.selected_index();
276        let task = self
277            .matches
278            .get(current_match_index)
279            .and_then(|current_match| {
280                let ix = current_match.candidate_id;
281                self.candidates
282                    .as_ref()
283                    .map(|candidates| candidates[ix].clone())
284            });
285        let Some((task_source_kind, task)) = task else {
286            return;
287        };
288
289        self.workspace
290            .update(cx, |workspace, cx| {
291                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
292            })
293            .ok();
294        cx.emit(DismissEvent);
295    }
296
297    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
298        cx.emit(DismissEvent);
299    }
300
301    fn render_match(
302        &self,
303        ix: usize,
304        selected: bool,
305        cx: &mut ViewContext<picker::Picker<Self>>,
306    ) -> Option<Self::ListItem> {
307        let candidates = self.candidates.as_ref()?;
308        let hit = &self.matches[ix];
309        let (source_kind, _) = &candidates.get(hit.candidate_id)?;
310
311        let highlighted_location = HighlightedText {
312            text: hit.string.clone(),
313            highlight_positions: hit.positions.clone(),
314            char_count: hit.string.chars().count(),
315        };
316        let icon = match source_kind {
317            TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
318            TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
319            TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
320            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
321                .get_type_icon(&name.to_lowercase())
322                .map(|icon_path| Icon::from_path(icon_path)),
323        };
324        Some(
325            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
326                .inset(true)
327                .spacing(ListItemSpacing::Sparse)
328                .map(|item| {
329                    let item = if matches!(source_kind, TaskSourceKind::UserInput)
330                        || Some(ix) <= self.last_used_candidate_index
331                    {
332                        let task_index = hit.candidate_id;
333                        let delete_button = div().child(
334                            IconButton::new("delete", IconName::Close)
335                                .shape(IconButtonShape::Square)
336                                .icon_color(Color::Muted)
337                                .size(ButtonSize::None)
338                                .icon_size(IconSize::XSmall)
339                                .on_click(cx.listener(move |picker, _event, cx| {
340                                    cx.stop_propagation();
341                                    cx.prevent_default();
342
343                                    picker.delegate.delete_previously_used(task_index, cx);
344                                    picker.delegate.last_used_candidate_index = picker
345                                        .delegate
346                                        .last_used_candidate_index
347                                        .unwrap_or(0)
348                                        .checked_sub(1);
349                                    picker.refresh(cx);
350                                }))
351                                .tooltip(|cx| {
352                                    Tooltip::text("Delete previously scheduled task", cx)
353                                }),
354                        );
355                        item.end_hover_slot(delete_button)
356                    } else {
357                        item
358                    };
359                    if let Some(icon) = icon {
360                        item.end_slot(icon)
361                    } else {
362                        item
363                    }
364                })
365                .selected(selected)
366                .child(highlighted_location.render(cx)),
367        )
368    }
369
370    fn selected_as_query(&self) -> Option<String> {
371        use itertools::intersperse;
372        let task_index = self.matches.get(self.selected_index())?.candidate_id;
373        let tasks = self.candidates.as_ref()?;
374        let (_, task) = tasks.get(task_index)?;
375        task.resolved.as_ref().map(|spawn_in_terminal| {
376            let mut command = spawn_in_terminal.command.clone();
377            if !spawn_in_terminal.args.is_empty() {
378                command.push(' ');
379                command.extend(intersperse(spawn_in_terminal.args.clone(), " ".to_string()));
380            }
381            command
382        })
383    }
384
385    fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
386        let Some((task_source_kind, task)) = self.spawn_oneshot() else {
387            return;
388        };
389        self.workspace
390            .update(cx, |workspace, cx| {
391                schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
392            })
393            .ok();
394        cx.emit(DismissEvent);
395    }
396
397    fn separators_after_indices(&self) -> Vec<usize> {
398        if let Some(i) = self.last_used_candidate_index {
399            vec![i]
400        } else {
401            Vec::new()
402        }
403    }
404}
405
406// TODO kb more tests on recent tasks from language templates
407#[cfg(test)]
408mod tests {
409    use gpui::{TestAppContext, VisualTestContext};
410    use project::{FakeFs, Project};
411    use serde_json::json;
412
413    use crate::modal::Spawn;
414
415    use super::*;
416
417    #[gpui::test]
418    async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
419        crate::tests::init_test(cx);
420        let fs = FakeFs::new(cx.executor());
421        fs.insert_tree(
422            "/dir",
423            json!({
424                ".zed": {
425                    "tasks.json": r#"[
426                        {
427                            "label": "example task",
428                            "command": "echo",
429                            "args": ["4"]
430                        },
431                        {
432                            "label": "another one",
433                            "command": "echo",
434                            "args": ["55"]
435                        },
436                    ]"#,
437                },
438                "a.ts": "a"
439            }),
440        )
441        .await;
442
443        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
444        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
445
446        let tasks_picker = open_spawn_tasks(&workspace, cx);
447        assert_eq!(
448            query(&tasks_picker, cx),
449            "",
450            "Initial query should be empty"
451        );
452        assert_eq!(
453            task_names(&tasks_picker, cx),
454            vec!["another one", "example task"],
455            "Initial tasks should be listed in alphabetical order"
456        );
457
458        let query_str = "tas";
459        cx.simulate_input(query_str);
460        assert_eq!(query(&tasks_picker, cx), query_str);
461        assert_eq!(
462            task_names(&tasks_picker, cx),
463            vec!["example task"],
464            "Only one task should match the query {query_str}"
465        );
466
467        cx.dispatch_action(picker::UseSelectedQuery);
468        assert_eq!(
469            query(&tasks_picker, cx),
470            "echo 4",
471            "Query should be set to the selected task's command"
472        );
473        assert_eq!(
474            task_names(&tasks_picker, cx),
475            Vec::<String>::new(),
476            "No task should be listed"
477        );
478        cx.dispatch_action(picker::ConfirmInput { secondary: false });
479
480        let tasks_picker = open_spawn_tasks(&workspace, cx);
481        assert_eq!(
482            query(&tasks_picker, cx),
483            "",
484            "Query should be reset after confirming"
485        );
486        assert_eq!(
487            task_names(&tasks_picker, cx),
488            vec!["echo 4", "another one", "example task"],
489            "New oneshot task should be listed first"
490        );
491
492        let query_str = "echo 4";
493        cx.simulate_input(query_str);
494        assert_eq!(query(&tasks_picker, cx), query_str);
495        assert_eq!(
496            task_names(&tasks_picker, cx),
497            vec!["echo 4"],
498            "New oneshot should match custom command query"
499        );
500
501        cx.dispatch_action(picker::ConfirmInput { secondary: false });
502        let tasks_picker = open_spawn_tasks(&workspace, cx);
503        assert_eq!(
504            query(&tasks_picker, cx),
505            "",
506            "Query should be reset after confirming"
507        );
508        assert_eq!(
509            task_names(&tasks_picker, cx),
510            vec![query_str, "another one", "example task"],
511            "Last recently used one show task should be listed first"
512        );
513
514        cx.dispatch_action(picker::UseSelectedQuery);
515        assert_eq!(
516            query(&tasks_picker, cx),
517            query_str,
518            "Query should be set to the custom task's name"
519        );
520        assert_eq!(
521            task_names(&tasks_picker, cx),
522            vec![query_str],
523            "Only custom task should be listed"
524        );
525
526        let query_str = "0";
527        cx.simulate_input(query_str);
528        assert_eq!(query(&tasks_picker, cx), "echo 40");
529        assert_eq!(
530            task_names(&tasks_picker, cx),
531            Vec::<String>::new(),
532            "New oneshot should not match any command query"
533        );
534
535        cx.dispatch_action(picker::ConfirmInput { secondary: true });
536        let tasks_picker = open_spawn_tasks(&workspace, cx);
537        assert_eq!(
538            query(&tasks_picker, cx),
539            "",
540            "Query should be reset after confirming"
541        );
542        assert_eq!(
543            task_names(&tasks_picker, cx),
544            vec!["echo 4", "another one", "example task"],
545            "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
546        );
547
548        cx.dispatch_action(Spawn {
549            task_name: Some("example task".to_string()),
550        });
551        let tasks_picker = workspace.update(cx, |workspace, cx| {
552            workspace
553                .active_modal::<TasksModal>(cx)
554                .unwrap()
555                .read(cx)
556                .picker
557                .clone()
558        });
559        assert_eq!(
560            task_names(&tasks_picker, cx),
561            vec!["echo 4", "another one", "example task"],
562        );
563    }
564
565    fn open_spawn_tasks(
566        workspace: &View<Workspace>,
567        cx: &mut VisualTestContext,
568    ) -> View<Picker<TasksModalDelegate>> {
569        cx.dispatch_action(Spawn::default());
570        workspace.update(cx, |workspace, cx| {
571            workspace
572                .active_modal::<TasksModal>(cx)
573                .unwrap()
574                .read(cx)
575                .picker
576                .clone()
577        })
578    }
579
580    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
581        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
582    }
583
584    fn task_names(
585        spawn_tasks: &View<Picker<TasksModalDelegate>>,
586        cx: &mut VisualTestContext,
587    ) -> Vec<String> {
588        spawn_tasks.update(cx, |spawn_tasks, _| {
589            spawn_tasks
590                .delegate
591                .matches
592                .iter()
593                .map(|hit| hit.string.clone())
594                .collect::<Vec<_>>()
595        })
596    }
597}