modal.rs

  1use std::sync::Arc;
  2
  3use fuzzy::{StringMatch, StringMatchCandidate};
  4use gpui::{
  5    actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
  6    Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
  7    VisualContext, WeakView,
  8};
  9use picker::{Picker, PickerDelegate};
 10use project::Inventory;
 11use task::{oneshot_source::OneshotSource, Task};
 12use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable, WindowContext};
 13use util::ResultExt;
 14use workspace::{ModalView, Workspace};
 15
 16use crate::schedule_task;
 17
 18actions!(task, [Spawn, Rerun]);
 19
 20/// A modal used to spawn new tasks.
 21pub(crate) struct TasksModalDelegate {
 22    inventory: Model<Inventory>,
 23    candidates: Vec<Arc<dyn Task>>,
 24    matches: Vec<StringMatch>,
 25    selected_index: usize,
 26    workspace: WeakView<Workspace>,
 27    prompt: String,
 28}
 29
 30impl TasksModalDelegate {
 31    fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
 32        Self {
 33            inventory,
 34            workspace,
 35            candidates: Vec::new(),
 36            matches: Vec::new(),
 37            selected_index: 0,
 38            prompt: String::default(),
 39        }
 40    }
 41
 42    fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
 43        self.inventory
 44            .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
 45            .update(cx, |oneshot_source, _| {
 46                Some(
 47                    oneshot_source
 48                        .as_any()
 49                        .downcast_mut::<OneshotSource>()?
 50                        .spawn(self.prompt.clone()),
 51                )
 52            })
 53    }
 54}
 55
 56pub(crate) struct TasksModal {
 57    picker: View<Picker<TasksModalDelegate>>,
 58    _subscription: Subscription,
 59}
 60
 61impl TasksModal {
 62    pub(crate) fn new(
 63        inventory: Model<Inventory>,
 64        workspace: WeakView<Workspace>,
 65        cx: &mut ViewContext<Self>,
 66    ) -> Self {
 67        let picker = cx
 68            .new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
 69        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
 70            cx.emit(DismissEvent);
 71        });
 72        Self {
 73            picker,
 74            _subscription,
 75        }
 76    }
 77}
 78
 79impl Render for TasksModal {
 80    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
 81        v_flex()
 82            .w(rems(34.))
 83            .child(self.picker.clone())
 84            .on_mouse_down_out(cx.listener(|modal, _, cx| {
 85                modal.picker.update(cx, |picker, cx| {
 86                    picker.cancel(&Default::default(), cx);
 87                })
 88            }))
 89    }
 90}
 91
 92impl EventEmitter<DismissEvent> for TasksModal {}
 93
 94impl FocusableView for TasksModal {
 95    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
 96        self.picker.read(cx).focus_handle(cx)
 97    }
 98}
 99
100impl ModalView for TasksModal {}
101
102impl PickerDelegate for TasksModalDelegate {
103    type ListItem = ListItem;
104
105    fn match_count(&self) -> usize {
106        self.matches.len()
107    }
108
109    fn selected_index(&self) -> usize {
110        self.selected_index
111    }
112
113    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
114        self.selected_index = ix;
115    }
116
117    fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
118        Arc::from(format!(
119            "{} runs the selected task, {} spawns a bash-like task from the prompt",
120            cx.keystroke_text_for(&menu::Confirm),
121            cx.keystroke_text_for(&menu::SecondaryConfirm),
122        ))
123    }
124
125    fn update_matches(
126        &mut self,
127        query: String,
128        cx: &mut ViewContext<picker::Picker<Self>>,
129    ) -> gpui::Task<()> {
130        cx.spawn(move |picker, mut cx| async move {
131            let Some(candidates) = picker
132                .update(&mut cx, |picker, cx| {
133                    picker.delegate.candidates = picker
134                        .delegate
135                        .inventory
136                        .update(cx, |inventory, cx| inventory.list_tasks(None, true, cx));
137                    picker
138                        .delegate
139                        .candidates
140                        .iter()
141                        .enumerate()
142                        .map(|(index, candidate)| StringMatchCandidate {
143                            id: index,
144                            char_bag: candidate.name().chars().collect(),
145                            string: candidate.name().into(),
146                        })
147                        .collect::<Vec<_>>()
148                })
149                .ok()
150            else {
151                return;
152            };
153            let matches = fuzzy::match_strings(
154                &candidates,
155                &query,
156                true,
157                1000,
158                &Default::default(),
159                cx.background_executor().clone(),
160            )
161            .await;
162            picker
163                .update(&mut cx, |picker, _| {
164                    let delegate = &mut picker.delegate;
165                    delegate.matches = matches;
166                    delegate.prompt = query;
167
168                    if delegate.matches.is_empty() {
169                        delegate.selected_index = 0;
170                    } else {
171                        delegate.selected_index =
172                            delegate.selected_index.min(delegate.matches.len() - 1);
173                    }
174                })
175                .log_err();
176        })
177    }
178
179    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
180        let current_match_index = self.selected_index();
181
182        let task = if secondary {
183            if !self.prompt.trim().is_empty() {
184                self.spawn_oneshot(cx)
185            } else {
186                None
187            }
188        } else {
189            self.matches.get(current_match_index).map(|current_match| {
190                let ix = current_match.candidate_id;
191                self.candidates[ix].clone()
192            })
193        };
194
195        let Some(task) = task else {
196            return;
197        };
198
199        self.workspace
200            .update(cx, |workspace, cx| {
201                schedule_task(workspace, task.as_ref(), cx);
202            })
203            .ok();
204        cx.emit(DismissEvent);
205    }
206
207    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
208        cx.emit(DismissEvent);
209    }
210
211    fn render_match(
212        &self,
213        ix: usize,
214        selected: bool,
215        _cx: &mut ViewContext<picker::Picker<Self>>,
216    ) -> Option<Self::ListItem> {
217        let hit = &self.matches[ix];
218        let highlights: Vec<_> = hit.positions.iter().copied().collect();
219        Some(
220            ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
221                .inset(true)
222                .spacing(ListItemSpacing::Sparse)
223                .selected(selected)
224                .start_slot(HighlightedLabel::new(hit.string.clone(), highlights)),
225        )
226    }
227}