snippets_ui.rs

  1use file_finder::file_finder_settings::FileFinderSettings;
  2use file_icons::FileIcons;
  3use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
  4use gpui::{
  5    App, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement, Render, Styled,
  6    WeakEntity, Window, actions,
  7};
  8use language::{LanguageMatcher, LanguageName, LanguageRegistry};
  9use paths::snippets_dir;
 10use picker::{Picker, PickerDelegate};
 11use settings::Settings;
 12use std::{
 13    borrow::{Borrow, Cow},
 14    collections::HashSet,
 15    fs,
 16    path::Path,
 17    sync::Arc,
 18};
 19use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
 20use util::ResultExt;
 21use workspace::{ModalView, OpenOptions, OpenVisible, Workspace, notifications::NotifyResultExt};
 22
 23#[derive(Eq, Hash, PartialEq)]
 24struct ScopeName(Cow<'static, str>);
 25
 26struct ScopeFileName(Cow<'static, str>);
 27
 28impl ScopeFileName {
 29    fn with_extension(self) -> String {
 30        format!("{}.json", self.0)
 31    }
 32}
 33
 34const GLOBAL_SCOPE_NAME: &str = "global";
 35const GLOBAL_SCOPE_FILE_NAME: &str = "snippets";
 36
 37impl From<ScopeName> for ScopeFileName {
 38    fn from(value: ScopeName) -> Self {
 39        if value.0 == GLOBAL_SCOPE_NAME {
 40            ScopeFileName(Cow::Borrowed(GLOBAL_SCOPE_FILE_NAME))
 41        } else {
 42            ScopeFileName(value.0)
 43        }
 44    }
 45}
 46
 47impl From<ScopeFileName> for ScopeName {
 48    fn from(value: ScopeFileName) -> Self {
 49        if value.0 == GLOBAL_SCOPE_FILE_NAME {
 50            ScopeName(Cow::Borrowed(GLOBAL_SCOPE_NAME))
 51        } else {
 52            ScopeName(value.0)
 53        }
 54    }
 55}
 56
 57actions!(snippets, [ConfigureSnippets, OpenFolder]);
 58
 59pub fn init(cx: &mut App) {
 60    cx.observe_new(register).detach();
 61}
 62
 63fn register(workspace: &mut Workspace, _window: Option<&mut Window>, _: &mut Context<Workspace>) {
 64    workspace.register_action(configure_snippets);
 65    workspace.register_action(open_folder);
 66}
 67
 68fn configure_snippets(
 69    workspace: &mut Workspace,
 70    _: &ConfigureSnippets,
 71    window: &mut Window,
 72    cx: &mut Context<Workspace>,
 73) {
 74    let language_registry = workspace.app_state().languages.clone();
 75    let workspace_handle = workspace.weak_handle();
 76
 77    workspace.toggle_modal(window, cx, move |window, cx| {
 78        ScopeSelector::new(language_registry, workspace_handle, window, cx)
 79    });
 80}
 81
 82fn open_folder(
 83    workspace: &mut Workspace,
 84    _: &OpenFolder,
 85    _: &mut Window,
 86    cx: &mut Context<Workspace>,
 87) {
 88    fs::create_dir_all(snippets_dir()).notify_err(workspace, cx);
 89    cx.open_with_system(snippets_dir().borrow());
 90}
 91
 92pub struct ScopeSelector {
 93    picker: Entity<Picker<ScopeSelectorDelegate>>,
 94}
 95
 96impl ScopeSelector {
 97    fn new(
 98        language_registry: Arc<LanguageRegistry>,
 99        workspace: WeakEntity<Workspace>,
100        window: &mut Window,
101        cx: &mut Context<Self>,
102    ) -> Self {
103        let delegate =
104            ScopeSelectorDelegate::new(workspace, cx.entity().downgrade(), language_registry);
105
106        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
107
108        Self { picker }
109    }
110}
111
112impl ModalView for ScopeSelector {}
113
114impl EventEmitter<DismissEvent> for ScopeSelector {}
115
116impl Focusable for ScopeSelector {
117    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
118        self.picker.focus_handle(cx)
119    }
120}
121
122impl Render for ScopeSelector {
123    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
124        v_flex().w(rems(34.)).child(self.picker.clone())
125    }
126}
127
128pub struct ScopeSelectorDelegate {
129    workspace: WeakEntity<Workspace>,
130    scope_selector: WeakEntity<ScopeSelector>,
131    language_registry: Arc<LanguageRegistry>,
132    candidates: Vec<StringMatchCandidate>,
133    matches: Vec<StringMatch>,
134    selected_index: usize,
135    existing_scopes: HashSet<ScopeName>,
136}
137
138impl ScopeSelectorDelegate {
139    fn new(
140        workspace: WeakEntity<Workspace>,
141        scope_selector: WeakEntity<ScopeSelector>,
142        language_registry: Arc<LanguageRegistry>,
143    ) -> Self {
144        let candidates = Vec::from([GLOBAL_SCOPE_NAME.to_string()]).into_iter();
145        let languages = language_registry.language_names().into_iter();
146
147        let candidates = candidates
148            .chain(languages)
149            .enumerate()
150            .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, &name))
151            .collect::<Vec<_>>();
152
153        let mut existing_scopes = HashSet::new();
154
155        if let Some(read_dir) = fs::read_dir(snippets_dir()).log_err() {
156            for entry in read_dir {
157                if let Some(entry) = entry.log_err() {
158                    let path = entry.path();
159                    if let (Some(stem), Some(extension)) = (path.file_stem(), path.extension()) {
160                        if extension.to_os_string().to_str() == Some("json") {
161                            if let Ok(file_name) = stem.to_os_string().into_string() {
162                                existing_scopes
163                                    .insert(ScopeName::from(ScopeFileName(Cow::Owned(file_name))));
164                            }
165                        }
166                    }
167                }
168            }
169        }
170
171        Self {
172            workspace,
173            scope_selector,
174            language_registry,
175            candidates,
176            matches: Vec::new(),
177            selected_index: 0,
178            existing_scopes,
179        }
180    }
181
182    fn scope_icon(&self, matcher: &LanguageMatcher, cx: &App) -> Option<Icon> {
183        matcher
184            .path_suffixes
185            .iter()
186            .find_map(|extension| FileIcons::get_icon(Path::new(extension), cx))
187            .or(FileIcons::get(cx).get_icon_for_type("default", cx))
188            .map(Icon::from_path)
189            .map(|icon| icon.color(Color::Muted))
190    }
191}
192
193impl PickerDelegate for ScopeSelectorDelegate {
194    type ListItem = ListItem;
195
196    fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc<str> {
197        "Select snippet scope...".into()
198    }
199
200    fn match_count(&self) -> usize {
201        self.matches.len()
202    }
203
204    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
205        if let Some(mat) = self.matches.get(self.selected_index) {
206            let scope_name = self.candidates[mat.candidate_id].string.clone();
207            let language = self.language_registry.language_for_name(&scope_name);
208
209            if let Some(workspace) = self.workspace.upgrade() {
210                cx.spawn_in(window, async move |_, cx| {
211                    let scope_file_name = ScopeFileName(match scope_name.to_lowercase().as_str() {
212                        GLOBAL_SCOPE_NAME => Cow::Borrowed(GLOBAL_SCOPE_FILE_NAME),
213                        _ => Cow::Owned(language.await?.lsp_id()),
214                    });
215
216                    workspace.update_in(cx, |workspace, window, cx| {
217                        workspace
218                            .open_abs_path(
219                                snippets_dir().join(scope_file_name.with_extension()),
220                                OpenOptions {
221                                    visible: Some(OpenVisible::None),
222                                    ..Default::default()
223                                },
224                                window,
225                                cx,
226                            )
227                            .detach();
228                    })
229                })
230                .detach_and_log_err(cx);
231            };
232        }
233        self.dismissed(window, cx);
234    }
235
236    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
237        self.scope_selector
238            .update(cx, |_, cx| cx.emit(DismissEvent))
239            .log_err();
240    }
241
242    fn selected_index(&self) -> usize {
243        self.selected_index
244    }
245
246    fn set_selected_index(
247        &mut self,
248        ix: usize,
249        _window: &mut Window,
250        _: &mut Context<Picker<Self>>,
251    ) {
252        self.selected_index = ix;
253    }
254
255    fn update_matches(
256        &mut self,
257        query: String,
258        window: &mut Window,
259        cx: &mut Context<Picker<Self>>,
260    ) -> gpui::Task<()> {
261        let background = cx.background_executor().clone();
262        let candidates = self.candidates.clone();
263        cx.spawn_in(window, async move |this, cx| {
264            let matches = if query.is_empty() {
265                candidates
266                    .into_iter()
267                    .enumerate()
268                    .map(|(index, candidate)| StringMatch {
269                        candidate_id: index,
270                        string: candidate.string,
271                        positions: Vec::new(),
272                        score: 0.0,
273                    })
274                    .collect()
275            } else {
276                match_strings(
277                    &candidates,
278                    &query,
279                    false,
280                    100,
281                    &Default::default(),
282                    background,
283                )
284                .await
285            };
286
287            this.update(cx, |this, cx| {
288                let delegate = &mut this.delegate;
289                delegate.matches = matches;
290                delegate.selected_index = delegate
291                    .selected_index
292                    .min(delegate.matches.len().saturating_sub(1));
293                cx.notify();
294            })
295            .log_err();
296        })
297    }
298
299    fn render_match(
300        &self,
301        ix: usize,
302        selected: bool,
303        _window: &mut Window,
304        cx: &mut Context<Picker<Self>>,
305    ) -> Option<Self::ListItem> {
306        let mat = &self.matches[ix];
307        let name_label = mat.string.clone();
308
309        let scope_name = ScopeName(Cow::Owned(
310            LanguageName::new(&self.candidates[mat.candidate_id].string).lsp_id(),
311        ));
312        let file_label = if self.existing_scopes.contains(&scope_name) {
313            Some(ScopeFileName::from(scope_name).with_extension())
314        } else {
315            None
316        };
317
318        let language_icon = if FileFinderSettings::get_global(cx).file_icons {
319            let language_name = LanguageName::new(mat.string.as_str());
320            self.language_registry
321                .available_language_for_name(language_name.as_ref())
322                .and_then(|available_language| self.scope_icon(available_language.matcher(), cx))
323                .or_else(|| {
324                    Some(
325                        Icon::from_path(IconName::Globe.path())
326                            .map(|icon| icon.color(Color::Muted)),
327                    )
328                })
329        } else {
330            None
331        };
332
333        Some(
334            ListItem::new(ix)
335                .inset(true)
336                .spacing(ListItemSpacing::Sparse)
337                .toggle_state(selected)
338                .start_slot::<Icon>(language_icon)
339                .child(
340                    h_flex()
341                        .gap_x_2()
342                        .child(HighlightedLabel::new(name_label, mat.positions.clone()))
343                        .when_some(file_label, |item, path_label| {
344                            item.child(
345                                Label::new(path_label)
346                                    .color(Color::Muted)
347                                    .size(LabelSize::Small),
348                            )
349                        }),
350                ),
351        )
352    }
353}