Add snippet commands (#18453)

loczek created

Closes #17860
Closes #15403

Release Notes:

- Added `snippets: configure snippets` command to create and modify
snippets
- Added `snippets: open folder` command for opening the
`~/.config/zed/snippets` directory


https://github.com/user-attachments/assets/fd9e664c-44b1-49bf-87a8-42b9e516f12f

Change summary

Cargo.lock                            |  15 +
Cargo.toml                            |   2 
crates/snippets_ui/Cargo.toml         |  22 ++
crates/snippets_ui/LICENSE-GPL        |   1 
crates/snippets_ui/src/snippets_ui.rs | 226 +++++++++++++++++++++++++++++
crates/zed/Cargo.toml                 |   1 
crates/zed/src/main.rs                |   1 
7 files changed, 268 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -10500,6 +10500,20 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "snippets_ui"
+version = "0.1.0"
+dependencies = [
+ "fuzzy",
+ "gpui",
+ "language",
+ "paths",
+ "picker",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "socket2"
 version = "0.4.10"
@@ -14468,6 +14482,7 @@ dependencies = [
  "simplelog",
  "smol",
  "snippet_provider",
+ "snippets_ui",
  "supermaven",
  "sysinfo",
  "tab_switcher",

Cargo.toml 🔗

@@ -99,6 +99,7 @@ members = [
     "crates/settings_ui",
     "crates/snippet",
     "crates/snippet_provider",
+    "crates/snippets_ui",
     "crates/sqlez",
     "crates/sqlez_macros",
     "crates/story",
@@ -275,6 +276,7 @@ settings = { path = "crates/settings" }
 settings_ui = { path = "crates/settings_ui" }
 snippet = { path = "crates/snippet" }
 snippet_provider = { path = "crates/snippet_provider" }
+snippets_ui = { path = "crates/snippets_ui" }
 sqlez = { path = "crates/sqlez" }
 sqlez_macros = { path = "crates/sqlez_macros" }
 story = { path = "crates/story" }

crates/snippets_ui/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "snippets_ui"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/snippets_ui.rs"
+
+[dependencies]
+fuzzy.workspace = true
+gpui.workspace = true
+language.workspace = true
+paths.workspace = true
+picker.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true

crates/snippets_ui/src/snippets_ui.rs 🔗

@@ -0,0 +1,226 @@
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{
+    actions, AppContext, DismissEvent, EventEmitter, FocusableView, ParentElement, Render, Styled,
+    View, ViewContext, VisualContext, WeakView,
+};
+use language::LanguageRegistry;
+use paths::config_dir;
+use picker::{Picker, PickerDelegate};
+use std::{borrow::Borrow, fs, sync::Arc};
+use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, WindowContext};
+use util::ResultExt;
+use workspace::{notifications::NotifyResultExt, ModalView, Workspace};
+
+actions!(snippets, [ConfigureSnippets, OpenFolder]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(register).detach();
+}
+
+fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+    workspace.register_action(configure_snippets);
+    workspace.register_action(open_folder);
+}
+
+fn configure_snippets(
+    workspace: &mut Workspace,
+    _: &ConfigureSnippets,
+    cx: &mut ViewContext<Workspace>,
+) {
+    let language_registry = workspace.app_state().languages.clone();
+    let workspace_handle = workspace.weak_handle();
+
+    workspace.toggle_modal(cx, move |cx| {
+        ScopeSelector::new(language_registry, workspace_handle, cx)
+    });
+}
+
+fn open_folder(workspace: &mut Workspace, _: &OpenFolder, cx: &mut ViewContext<Workspace>) {
+    fs::create_dir_all(config_dir().join("snippets")).notify_err(workspace, cx);
+    cx.open_with_system(config_dir().join("snippets").borrow());
+}
+
+pub struct ScopeSelector {
+    picker: View<Picker<ScopeSelectorDelegate>>,
+}
+
+impl ScopeSelector {
+    fn new(
+        language_registry: Arc<LanguageRegistry>,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let delegate =
+            ScopeSelectorDelegate::new(workspace, cx.view().downgrade(), language_registry);
+
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
+
+        Self { picker }
+    }
+}
+
+impl ModalView for ScopeSelector {}
+
+impl EventEmitter<DismissEvent> for ScopeSelector {}
+
+impl FocusableView for ScopeSelector {
+    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for ScopeSelector {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+pub struct ScopeSelectorDelegate {
+    workspace: WeakView<Workspace>,
+    scope_selector: WeakView<ScopeSelector>,
+    language_registry: Arc<LanguageRegistry>,
+    candidates: Vec<StringMatchCandidate>,
+    matches: Vec<StringMatch>,
+    selected_index: usize,
+}
+
+impl ScopeSelectorDelegate {
+    fn new(
+        workspace: WeakView<Workspace>,
+        scope_selector: WeakView<ScopeSelector>,
+        language_registry: Arc<LanguageRegistry>,
+    ) -> Self {
+        let candidates = Vec::from(["Global".to_string()]).into_iter();
+        let languages = language_registry.language_names().into_iter();
+
+        let candidates = candidates
+            .chain(languages)
+            .enumerate()
+            .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name))
+            .collect::<Vec<_>>();
+
+        Self {
+            workspace,
+            scope_selector,
+            language_registry,
+            candidates,
+            matches: vec![],
+            selected_index: 0,
+        }
+    }
+}
+
+impl PickerDelegate for ScopeSelectorDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
+        "Select snippet scope...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some(mat) = self.matches.get(self.selected_index) {
+            let scope_name = self.candidates[mat.candidate_id].string.clone();
+            let language = self.language_registry.language_for_name(&scope_name);
+
+            if let Some(workspace) = self.workspace.upgrade() {
+                cx.spawn(|_, mut cx| async move {
+                    let scope = match scope_name.as_str() {
+                        "Global" => "snippets".to_string(),
+                        _ => language.await?.lsp_id(),
+                    };
+
+                    workspace.update(&mut cx, |workspace, cx| {
+                        workspace
+                            .open_abs_path(
+                                config_dir().join("snippets").join(scope + ".json"),
+                                false,
+                                cx,
+                            )
+                            .detach();
+                    })
+                })
+                .detach_and_log_err(cx);
+            };
+        }
+        self.dismissed(cx);
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        self.scope_selector
+            .update(cx, |_, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let background = cx.background_executor().clone();
+        let candidates = self.candidates.clone();
+        cx.spawn(|this, mut cx| async move {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+            };
+
+            this.update(&mut cx, |this, cx| {
+                let delegate = &mut this.delegate;
+                delegate.matches = matches;
+                delegate.selected_index = delegate
+                    .selected_index
+                    .min(delegate.matches.len().saturating_sub(1));
+                cx.notify();
+            })
+            .log_err();
+        })
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let mat = &self.matches[ix];
+        let label = mat.string.clone();
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .selected(selected)
+                .child(HighlightedLabel::new(label, mat.positions.clone())),
+        )
+    }
+}

crates/zed/Cargo.toml 🔗

@@ -96,6 +96,7 @@ shellexpand.workspace = true
 simplelog.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true
+snippets_ui.workspace = true
 supermaven.workspace = true
 sysinfo.workspace = true
 tab_switcher.workspace = true

crates/zed/src/main.rs 🔗

@@ -256,6 +256,7 @@ fn init_ui(
     project_panel::init(Assets, cx);
     outline_panel::init(Assets, cx);
     tasks_ui::init(cx);
+    snippets_ui::init(cx);
     channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx);
     search::init(cx);
     vim::init(cx);