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