Merge pull request #823 from zed-industries/command-palette

Max Brunsfeld created

Command palette

Change summary

Cargo.lock                                    |  38 ++
assets/keymaps/default.json                   |  16 
assets/themes/dark.json                       |  23 +
assets/themes/light.json                      |  23 +
crates/command_palette/Cargo.toml             |  26 +
crates/command_palette/src/command_palette.rs | 362 ++++++++++++++++++++
crates/file_finder/Cargo.toml                 |   1 
crates/file_finder/src/file_finder.rs         | 367 ++++++--------------
crates/gpui/src/app.rs                        |  63 +++
crates/gpui/src/executor.rs                   |  44 ++
crates/gpui/src/keymap.rs                     |  55 ++
crates/gpui/src/presenter.rs                  |  20 
crates/outline/Cargo.toml                     |   1 
crates/outline/src/outline.rs                 | 224 +++---------
crates/picker/Cargo.toml                      |  23 +
crates/picker/src/picker.rs                   | 272 +++++++++++++++
crates/project_symbols/Cargo.toml             |   1 
crates/project_symbols/src/project_symbols.rs | 334 ++++++------------
crates/theme/src/theme.rs                     |   9 
crates/theme_selector/Cargo.toml              |   1 
crates/theme_selector/src/theme_selector.rs   | 332 ++++++------------
crates/workspace/src/menu.rs                  |  14 
crates/zed/Cargo.toml                         |   2 
crates/zed/src/main.rs                        |   1 
styles/src/styleTree/app.ts                   |   2 
styles/src/styleTree/commandPalette.ts        |  21 +
styles/src/styleTree/selectorModal.ts         |   2 
27 files changed, 1,379 insertions(+), 898 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1143,6 +1143,23 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
 
+[[package]]
+name = "command_palette"
+version = "0.1.0"
+dependencies = [
+ "ctor",
+ "editor",
+ "env_logger 0.8.3",
+ "fuzzy",
+ "gpui",
+ "picker",
+ "serde_json",
+ "settings",
+ "theme",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "comrak"
 version = "0.10.1"
@@ -1890,6 +1907,7 @@ dependencies = [
  "env_logger 0.8.3",
  "fuzzy",
  "gpui",
+ "picker",
  "postage",
  "project",
  "serde_json",
@@ -3356,6 +3374,7 @@ dependencies = [
  "gpui",
  "language",
  "ordered-float",
+ "picker",
  "postage",
  "settings",
  "smol",
@@ -3529,6 +3548,21 @@ dependencies = [
  "indexmap",
 ]
 
+[[package]]
+name = "picker"
+version = "0.1.0"
+dependencies = [
+ "ctor",
+ "editor",
+ "env_logger 0.8.3",
+ "gpui",
+ "serde_json",
+ "settings",
+ "theme",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "pico-args"
 version = "0.4.0"
@@ -3762,6 +3796,7 @@ dependencies = [
  "fuzzy",
  "gpui",
  "ordered-float",
+ "picker",
  "postage",
  "project",
  "settings",
@@ -5278,6 +5313,7 @@ dependencies = [
  "gpui",
  "log",
  "parking_lot",
+ "picker",
  "postage",
  "settings",
  "smol",
@@ -6138,8 +6174,8 @@ dependencies = [
  "client",
  "clock",
  "collections",
+ "command_palette",
  "contacts_panel",
- "crossbeam-channel",
  "ctor",
  "diagnostics",
  "dirs 3.0.1",

assets/keymaps/default.json 🔗

@@ -16,7 +16,8 @@
         "ctrl-n": "menu::SelectNext",
         "cmd-up": "menu::SelectFirst",
         "cmd-down": "menu::SelectLast",
-        "enter": "menu::Confirm"
+        "enter": "menu::Confirm",
+        "escape": "menu::Cancel"
     },
     "Pane": {
         "shift-cmd-{": "pane::ActivatePrevItem",
@@ -52,6 +53,7 @@
         "cmd-k t": "theme_selector::Reload",
         "cmd-t": "project_symbols::Toggle",
         "cmd-p": "file_finder::Toggle",
+        "cmd-shift-P": "command_palette::Toggle",
         "alt-shift-D": "diagnostics::Deploy",
         "ctrl-alt-cmd-j": "journal::NewJournalEntry",
         "cmd-1": [
@@ -248,22 +250,10 @@
             "\n"
         ]
     },
-    "OutlineView": {
-        "escape": "outline::Toggle"
-    },
-    "ProjectSymbolsView": {
-        "escape": "project_symbols::Toggle"
-    },
-    "ThemeSelector": {
-        "escape": "theme_selector::Toggle"
-    },
     "GoToLine": {
         "escape": "go_to_line::Toggle",
         "enter": "go_to_line::Confirm"
     },
-    "FileFinder": {
-        "escape": "file_finder::Toggle"
-    },
     "ChatPanel": {
         "enter": "chat_panel::Send"
     },

assets/themes/dark.json 🔗

@@ -708,6 +708,29 @@
       }
     }
   },
+  "command_palette": {
+    "keystroke_spacing": 8,
+    "key": {
+      "text": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 12
+      },
+      "corner_radius": 3,
+      "background": "#2472f2",
+      "border": {
+        "color": "#2472f2",
+        "width": 1
+      },
+      "padding": {
+        "left": 3,
+        "right": 3
+      },
+      "margin": {
+        "left": 3
+      }
+    }
+  },
   "project_panel": {
     "padding": {
       "top": 6,

assets/themes/light.json 🔗

@@ -708,6 +708,29 @@
       }
     }
   },
+  "command_palette": {
+    "keystroke_spacing": 8,
+    "key": {
+      "text": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 12
+      },
+      "corner_radius": 3,
+      "background": "#c5dafc",
+      "border": {
+        "color": "#9ec1fa",
+        "width": 1
+      },
+      "padding": {
+        "left": 3,
+        "right": 3
+      },
+      "margin": {
+        "left": 3
+      }
+    }
+  },
   "project_panel": {
     "padding": {
       "top": 6,

crates/command_palette/Cargo.toml 🔗

@@ -0,0 +1,26 @@
+[package]
+name = "command_palette"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/command_palette.rs"
+doctest = false
+
+[dependencies]
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
+gpui = { path = "../gpui" }
+picker = { path = "../picker" }
+settings = { path = "../settings" }
+util = { path = "../util" }
+theme = { path = "../theme" }
+workspace = { path = "../workspace" }
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+editor = { path = "../editor", features = ["test-support"] }
+serde_json = { version = "1.0.64", features = ["preserve_order"] }
+workspace = { path = "../workspace", features = ["test-support"] }
+ctor = "0.1"
+env_logger = "0.8"

crates/command_palette/src/command_palette.rs 🔗

@@ -0,0 +1,362 @@
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::{ChildView, Flex, Label, ParentElement},
+    keymap::Keystroke,
+    Action, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate};
+use settings::Settings;
+use std::cmp;
+use workspace::Workspace;
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(CommandPalette::toggle);
+    Picker::<CommandPalette>::init(cx);
+}
+
+actions!(command_palette, [Toggle]);
+
+pub struct CommandPalette {
+    picker: ViewHandle<Picker<Self>>,
+    actions: Vec<Command>,
+    matches: Vec<StringMatch>,
+    selected_ix: usize,
+    focused_view_id: usize,
+}
+
+pub enum Event {
+    Dismissed,
+    Confirmed {
+        window_id: usize,
+        focused_view_id: usize,
+        action: Box<dyn Action>,
+    },
+}
+
+struct Command {
+    name: String,
+    action: Box<dyn Action>,
+    keystrokes: Vec<Keystroke>,
+}
+
+impl CommandPalette {
+    pub fn new(focused_view_id: usize, cx: &mut ViewContext<Self>) -> Self {
+        let this = cx.weak_handle();
+        let actions = cx
+            .available_actions(cx.window_id(), focused_view_id)
+            .map(|(name, action, bindings)| Command {
+                name: humanize_action_name(name),
+                action,
+                keystrokes: bindings
+                    .last()
+                    .map_or(Vec::new(), |binding| binding.keystrokes().to_vec()),
+            })
+            .collect();
+        let picker = cx.add_view(|cx| Picker::new(this, cx));
+        Self {
+            picker,
+            actions,
+            matches: vec![],
+            selected_ix: 0,
+            focused_view_id,
+        }
+    }
+
+    fn toggle(_: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
+        let workspace = cx.handle();
+        let window_id = cx.window_id();
+        let focused_view_id = cx.focused_view_id(window_id).unwrap_or(workspace.id());
+
+        cx.as_mut().defer(move |cx| {
+            let this = cx.add_view(window_id, |cx| Self::new(focused_view_id, cx));
+            workspace.update(cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |cx, _| {
+                    cx.subscribe(&this, Self::on_event).detach();
+                    this
+                });
+            });
+        });
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => workspace.dismiss_modal(cx),
+            Event::Confirmed {
+                window_id,
+                focused_view_id,
+                action,
+            } => {
+                let window_id = *window_id;
+                let focused_view_id = *focused_view_id;
+                let action = (*action).boxed_clone();
+                workspace.dismiss_modal(cx);
+                cx.as_mut()
+                    .defer(move |cx| cx.dispatch_action_at(window_id, focused_view_id, &*action))
+            }
+        }
+    }
+}
+
+impl Entity for CommandPalette {
+    type Event = Event;
+}
+
+impl View for CommandPalette {
+    fn ui_name() -> &'static str {
+        "CommandPalette"
+    }
+
+    fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.picker);
+    }
+}
+
+impl PickerDelegate for CommandPalette {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_ix
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
+        self.selected_ix = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut gpui::ViewContext<Self>,
+    ) -> gpui::Task<()> {
+        let candidates = self
+            .actions
+            .iter()
+            .enumerate()
+            .map(|(ix, command)| StringMatchCandidate {
+                id: ix,
+                string: command.name.to_string(),
+                char_bag: command.name.chars().collect(),
+            })
+            .collect::<Vec<_>>();
+        cx.spawn(move |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 {
+                fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    true,
+                    10000,
+                    &Default::default(),
+                    cx.background(),
+                )
+                .await
+            };
+            this.update(&mut cx, |this, _| {
+                this.matches = matches;
+                if this.matches.is_empty() {
+                    this.selected_ix = 0;
+                } else {
+                    this.selected_ix = cmp::min(this.selected_ix, this.matches.len() - 1);
+                }
+            });
+        })
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.matches.is_empty() {
+            let action_ix = self.matches[self.selected_ix].candidate_id;
+            cx.emit(Event::Confirmed {
+                window_id: cx.window_id(),
+                focused_view_id: self.focused_view_id,
+                action: self.actions.remove(action_ix).action,
+            });
+        } else {
+            cx.emit(Event::Dismissed);
+        }
+    }
+
+    fn render_match(&self, ix: usize, selected: bool, cx: &gpui::AppContext) -> gpui::ElementBox {
+        let mat = &self.matches[ix];
+        let command = &self.actions[mat.candidate_id];
+        let settings = cx.global::<Settings>();
+        let theme = &settings.theme;
+        let style = if selected {
+            &theme.selector.active_item
+        } else {
+            &theme.selector.item
+        };
+        let key_style = &theme.command_palette.key;
+        let keystroke_spacing = theme.command_palette.keystroke_spacing;
+
+        Flex::row()
+            .with_child(
+                Label::new(mat.string.clone(), style.label.clone())
+                    .with_highlights(mat.positions.clone())
+                    .boxed(),
+            )
+            .with_children(command.keystrokes.iter().map(|keystroke| {
+                Flex::row()
+                    .with_children(
+                        [
+                            (keystroke.ctrl, "^"),
+                            (keystroke.alt, "⎇"),
+                            (keystroke.cmd, "⌘"),
+                            (keystroke.shift, "⇧"),
+                        ]
+                        .into_iter()
+                        .filter_map(|(modifier, label)| {
+                            if modifier {
+                                Some(
+                                    Label::new(label.into(), key_style.label.clone())
+                                        .contained()
+                                        .with_style(key_style.container)
+                                        .boxed(),
+                                )
+                            } else {
+                                None
+                            }
+                        }),
+                    )
+                    .with_child(
+                        Label::new(keystroke.key.clone(), key_style.label.clone())
+                            .contained()
+                            .with_style(key_style.container)
+                            .boxed(),
+                    )
+                    .contained()
+                    .with_margin_left(keystroke_spacing)
+                    .flex_float()
+                    .boxed()
+            }))
+            .contained()
+            .with_style(style.container)
+            .boxed()
+    }
+}
+
+fn humanize_action_name(name: &str) -> String {
+    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
+    let mut result = String::with_capacity(capacity);
+    for char in name.chars() {
+        if char == ':' {
+            if result.ends_with(':') {
+                result.push(' ');
+            } else {
+                result.push(':');
+            }
+        } else if char.is_uppercase() {
+            if !result.ends_with(' ') {
+                result.push(' ');
+            }
+            result.extend(char.to_lowercase());
+        } else {
+            result.push(char);
+        }
+    }
+    result
+}
+
+impl std::fmt::Debug for Command {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Command")
+            .field("name", &self.name)
+            .field("keystrokes", &self.keystrokes)
+            .finish()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use editor::Editor;
+    use gpui::TestAppContext;
+    use workspace::{Workspace, WorkspaceParams};
+
+    #[test]
+    fn test_humanize_action_name() {
+        assert_eq!(
+            &humanize_action_name("editor::GoToDefinition"),
+            "editor: go to definition"
+        );
+        assert_eq!(
+            &humanize_action_name("editor::Backspace"),
+            "editor: backspace"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_command_palette(cx: &mut TestAppContext) {
+        let params = cx.update(WorkspaceParams::test);
+
+        cx.update(|cx| {
+            editor::init(cx);
+            workspace::init(&params.client, cx);
+            init(cx);
+        });
+
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+        let editor = cx.add_view(window_id, |cx| {
+            let mut editor = Editor::single_line(None, cx);
+            editor.set_text("abc", cx);
+            editor
+        });
+
+        workspace.update(cx, |workspace, cx| {
+            cx.focus(editor.clone());
+            workspace.add_item(Box::new(editor.clone()), cx)
+        });
+
+        workspace.update(cx, |workspace, cx| {
+            CommandPalette::toggle(workspace, &Toggle, cx)
+        });
+
+        let palette = workspace.read_with(cx, |workspace, _| {
+            workspace
+                .modal()
+                .unwrap()
+                .clone()
+                .downcast::<CommandPalette>()
+                .unwrap()
+        });
+
+        palette
+            .update(cx, |palette, cx| {
+                palette.update_matches("bcksp".to_string(), cx)
+            })
+            .await;
+
+        palette.update(cx, |palette, cx| {
+            assert_eq!(palette.matches[0].string, "editor: backspace");
+            palette.confirm(cx);
+        });
+
+        editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.text(cx), "ab");
+        });
+    }
+}

crates/file_finder/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 util = { path = "../util" }

crates/file_finder/src/file_finder.rs 🔗

@@ -1,13 +1,12 @@
-use editor::Editor;
 use fuzzy::PathMatch;
 use gpui::{
-    actions, elements::*, impl_internal_actions, keymap, AppContext, Axis, Entity, ModelHandle,
-    MutableAppContext, RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task,
+    View, ViewContext, ViewHandle,
 };
+use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectPath, WorktreeId};
 use settings::Settings;
 use std::{
-    cmp,
     path::Path,
     sync::{
         atomic::{self, AtomicBool},
@@ -15,15 +14,11 @@ use std::{
     },
 };
 use util::post_inc;
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 pub struct FileFinder {
-    handle: WeakViewHandle<Self>,
     project: ModelHandle<Project>,
-    query_editor: ViewHandle<Editor>,
+    picker: ViewHandle<Picker<Self>>,
     search_count: usize,
     latest_search_id: usize,
     latest_search_did_cancel: bool,
@@ -31,21 +26,13 @@ pub struct FileFinder {
     matches: Vec<PathMatch>,
     selected: Option<(usize, Arc<Path>)>,
     cancel_flag: Arc<AtomicBool>,
-    list_state: UniformListState,
 }
 
-#[derive(Clone)]
-pub struct Select(pub ProjectPath);
-
 actions!(file_finder, [Toggle]);
-impl_internal_actions!(file_finder, [Select]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(FileFinder::toggle);
-    cx.add_action(FileFinder::confirm);
-    cx.add_action(FileFinder::select);
-    cx.add_action(FileFinder::select_prev);
-    cx.add_action(FileFinder::select_next);
+    Picker::<FileFinder>::init(cx);
 }
 
 pub enum Event {
@@ -62,140 +49,16 @@ impl View for FileFinder {
         "FileFinder"
     }
 
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let settings = cx.global::<Settings>();
-        Align::new(
-            ConstrainedBox::new(
-                Container::new(
-                    Flex::new(Axis::Vertical)
-                        .with_child(
-                            ChildView::new(&self.query_editor)
-                                .contained()
-                                .with_style(settings.theme.selector.input_editor.container)
-                                .boxed(),
-                        )
-                        .with_child(
-                            FlexItem::new(self.render_matches(cx))
-                                .flex(1., false)
-                                .boxed(),
-                        )
-                        .boxed(),
-                )
-                .with_style(settings.theme.selector.container)
-                .boxed(),
-            )
-            .with_max_width(500.0)
-            .with_max_height(420.0)
-            .boxed(),
-        )
-        .top()
-        .named("file finder")
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.query_editor);
-    }
-
-    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
+        cx.focus(&self.picker);
     }
 }
 
 impl FileFinder {
-    fn render_matches(&self, cx: &AppContext) -> ElementBox {
-        if self.matches.is_empty() {
-            let settings = cx.global::<Settings>();
-            return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.theme.selector.empty.label.clone(),
-                )
-                .boxed(),
-            )
-            .with_style(settings.theme.selector.empty.container)
-            .named("empty matches");
-        }
-
-        let handle = self.handle.clone();
-        let list =
-            UniformList::new(
-                self.list_state.clone(),
-                self.matches.len(),
-                move |mut range, items, cx| {
-                    let cx = cx.as_ref();
-                    let finder = handle.upgrade(cx).unwrap();
-                    let finder = finder.read(cx);
-                    let start = range.start;
-                    range.end = cmp::min(range.end, finder.matches.len());
-                    items.extend(finder.matches[range].iter().enumerate().map(
-                        move |(i, path_match)| finder.render_match(path_match, start + i, cx),
-                    ));
-                },
-            );
-
-        Container::new(list.boxed())
-            .with_margin_top(6.0)
-            .named("matches")
-    }
-
-    fn render_match(&self, path_match: &PathMatch, index: usize, cx: &AppContext) -> ElementBox {
-        let selected_index = self.selected_index();
-        let settings = cx.global::<Settings>();
-        let style = if index == selected_index {
-            &settings.theme.selector.active_item
-        } else {
-            &settings.theme.selector.item
-        };
-        let (file_name, file_name_positions, full_path, full_path_positions) =
-            self.labels_for_match(path_match);
-        let container = Container::new(
-            Flex::row()
-                // .with_child(
-                //     Container::new(
-                //         LineBox::new(
-                //             Svg::new("icons/file-16.svg")
-                //                 .with_color(style.label.text.color)
-                //                 .boxed(),
-                //             style.label.text.clone(),
-                //         )
-                //         .boxed(),
-                //     )
-                //     .with_padding_right(6.0)
-                //     .boxed(),
-                // )
-                .with_child(
-                    Flex::column()
-                        .with_child(
-                            Label::new(file_name.to_string(), style.label.clone())
-                                .with_highlights(file_name_positions)
-                                .boxed(),
-                        )
-                        .with_child(
-                            Label::new(full_path, style.label.clone())
-                                .with_highlights(full_path_positions)
-                                .boxed(),
-                        )
-                        .flex(1., false)
-                        .boxed(),
-                )
-                .boxed(),
-        )
-        .with_style(style.container);
-
-        let action = Select(ProjectPath {
-            worktree_id: WorktreeId::from_usize(path_match.worktree_id),
-            path: path_match.path.clone(),
-        });
-        EventHandler::new(container.boxed())
-            .on_mouse_down(move |cx| {
-                cx.dispatch_action(action.clone());
-                true
-            })
-            .named("match")
-    }
-
     fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
         let path_string = path_match.path.to_string_lossy();
         let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
@@ -250,18 +113,11 @@ impl FileFinder {
     }
 
     pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+        let handle = cx.weak_handle();
         cx.observe(&project, Self::project_updated).detach();
-
-        let query_editor = cx.add_view(|cx| {
-            Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx)
-        });
-        cx.subscribe(&query_editor, Self::on_query_editor_event)
-            .detach();
-
         Self {
-            handle: cx.weak_handle(),
             project,
-            query_editor,
+            picker: cx.add_view(|cx| Picker::new(handle, cx)),
             search_count: 0,
             latest_search_id: 0,
             latest_search_did_cancel: false,
@@ -269,40 +125,60 @@ impl FileFinder {
             matches: Vec::new(),
             selected: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
-            list_state: Default::default(),
         }
     }
 
     fn project_updated(&mut self, _: ModelHandle<Project>, cx: &mut ViewContext<Self>) {
-        let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
-        if let Some(task) = self.spawn_search(query, cx) {
-            task.detach();
-        }
+        self.spawn_search(self.latest_search_query.clone(), cx)
+            .detach();
+    }
+
+    fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        let search_id = util::post_inc(&mut self.search_count);
+        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
+        self.cancel_flag = Arc::new(AtomicBool::new(false));
+        let cancel_flag = self.cancel_flag.clone();
+        let project = self.project.clone();
+        cx.spawn(|this, mut cx| async move {
+            let matches = project
+                .read_with(&cx, |project, cx| {
+                    project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx)
+                })
+                .await;
+            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
+            this.update(&mut cx, |this, cx| {
+                this.set_matches(search_id, did_cancel, query, matches, cx)
+            });
+        })
     }
 
-    fn on_query_editor_event(
+    fn set_matches(
         &mut self,
-        _: ViewHandle<Editor>,
-        event: &editor::Event,
+        search_id: usize,
+        did_cancel: bool,
+        query: String,
+        matches: Vec<PathMatch>,
         cx: &mut ViewContext<Self>,
     ) {
-        match event {
-            editor::Event::BufferEdited { .. } => {
-                let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
-                if query.is_empty() {
-                    self.latest_search_id = post_inc(&mut self.search_count);
-                    self.matches.clear();
-                    cx.notify();
-                } else {
-                    if let Some(task) = self.spawn_search(query, cx) {
-                        task.detach();
-                    }
-                }
+        if search_id >= self.latest_search_id {
+            self.latest_search_id = search_id;
+            if self.latest_search_did_cancel && query == self.latest_search_query {
+                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
+            } else {
+                self.matches = matches;
             }
-            editor::Event::Blurred => cx.emit(Event::Dismissed),
-            _ => {}
+            self.latest_search_query = query;
+            self.latest_search_did_cancel = did_cancel;
+            cx.notify();
+            self.picker.update(cx, |_, cx| cx.notify());
         }
     }
+}
+
+impl PickerDelegate for FileFinder {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
 
     fn selected_index(&self) -> usize {
         if let Some(selected) = self.selected.as_ref() {
@@ -317,31 +193,24 @@ impl FileFinder {
         0
     }
 
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        let mut selected_index = self.selected_index();
-        if selected_index > 0 {
-            selected_index -= 1;
-            let mat = &self.matches[selected_index];
-            self.selected = Some((mat.worktree_id, mat.path.clone()));
-        }
-        self.list_state
-            .scroll_to(ScrollTarget::Show(selected_index));
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
+        let mat = &self.matches[ix];
+        self.selected = Some((mat.worktree_id, mat.path.clone()));
         cx.notify();
     }
 
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        let mut selected_index = self.selected_index();
-        if selected_index + 1 < self.matches.len() {
-            selected_index += 1;
-            let mat = &self.matches[selected_index];
-            self.selected = Some((mat.worktree_id, mat.path.clone()));
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        if query.is_empty() {
+            self.latest_search_id = post_inc(&mut self.search_count);
+            self.matches.clear();
+            cx.notify();
+            Task::ready(())
+        } else {
+            self.spawn_search(query, cx)
         }
-        self.list_state
-            .scroll_to(ScrollTarget::Show(selected_index));
-        cx.notify();
     }
 
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(m) = self.matches.get(self.selected_index()) {
             cx.emit(Event::Selected(ProjectPath {
                 worktree_id: WorktreeId::from_usize(m.worktree_id),
@@ -350,57 +219,45 @@ impl FileFinder {
         }
     }
 
-    fn select(&mut self, Select(project_path): &Select, cx: &mut ViewContext<Self>) {
-        cx.emit(Event::Selected(project_path.clone()));
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
     }
 
-    #[must_use]
-    fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Option<Task<()>> {
-        let search_id = util::post_inc(&mut self.search_count);
-        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
-        self.cancel_flag = Arc::new(AtomicBool::new(false));
-        let cancel_flag = self.cancel_flag.clone();
-        let project = self.project.clone();
-        Some(cx.spawn(|this, mut cx| async move {
-            let matches = project
-                .read_with(&cx, |project, cx| {
-                    project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx)
-                })
-                .await;
-            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
-            this.update(&mut cx, |this, cx| {
-                this.update_matches((search_id, did_cancel, query, matches), cx)
-            });
-        }))
-    }
-
-    fn update_matches(
-        &mut self,
-        (search_id, did_cancel, query, matches): (usize, bool, String, Vec<PathMatch>),
-        cx: &mut ViewContext<Self>,
-    ) {
-        if search_id >= self.latest_search_id {
-            self.latest_search_id = search_id;
-            if self.latest_search_did_cancel && query == self.latest_search_query {
-                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
-            } else {
-                self.matches = matches;
-            }
-            self.latest_search_query = query;
-            self.latest_search_did_cancel = did_cancel;
-            self.list_state
-                .scroll_to(ScrollTarget::Show(self.selected_index()));
-            cx.notify();
-        }
+    fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox {
+        let path_match = &self.matches[ix];
+        let settings = cx.global::<Settings>();
+        let style = if selected {
+            &settings.theme.selector.active_item
+        } else {
+            &settings.theme.selector.item
+        };
+        let (file_name, file_name_positions, full_path, full_path_positions) =
+            self.labels_for_match(path_match);
+        Flex::column()
+            .with_child(
+                Label::new(file_name.to_string(), style.label.clone())
+                    .with_highlights(file_name_positions)
+                    .boxed(),
+            )
+            .with_child(
+                Label::new(full_path, style.label.clone())
+                    .with_highlights(full_path_positions)
+                    .boxed(),
+            )
+            .flex(1., false)
+            .contained()
+            .with_style(style.container)
+            .named("match")
     }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
-    use editor::Input;
+    use editor::{Editor, Input};
     use serde_json::json;
     use std::path::PathBuf;
+    use workspace::menu::{Confirm, SelectNext};
     use workspace::{Workspace, WorkspaceParams};
 
     #[ctor::ctor]
@@ -514,7 +371,6 @@ mod tests {
         let query = "hi".to_string();
         finder
             .update(cx, |f, cx| f.spawn_search(query.clone(), cx))
-            .unwrap()
             .await;
         finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 5));
 
@@ -523,26 +379,22 @@ mod tests {
 
             // Simulate a search being cancelled after the time limit,
             // returning only a subset of the matches that would have been found.
-            finder.spawn_search(query.clone(), cx).unwrap().detach();
-            finder.update_matches(
-                (
-                    finder.latest_search_id,
-                    true, // did-cancel
-                    query.clone(),
-                    vec![matches[1].clone(), matches[3].clone()],
-                ),
+            finder.spawn_search(query.clone(), cx).detach();
+            finder.set_matches(
+                finder.latest_search_id,
+                true, // did-cancel
+                query.clone(),
+                vec![matches[1].clone(), matches[3].clone()],
                 cx,
             );
 
             // Simulate another cancellation.
-            finder.spawn_search(query.clone(), cx).unwrap().detach();
-            finder.update_matches(
-                (
-                    finder.latest_search_id,
-                    true, // did-cancel
-                    query.clone(),
-                    vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
-                ),
+            finder.spawn_search(query.clone(), cx).detach();
+            finder.set_matches(
+                finder.latest_search_id,
+                true, // did-cancel
+                query.clone(),
+                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
                 cx,
             );
 
@@ -576,7 +428,6 @@ mod tests {
         // is included in the matching, because the worktree is a single file.
         finder
             .update(cx, |f, cx| f.spawn_search("thf".into(), cx))
-            .unwrap()
             .await;
         cx.read(|cx| {
             let finder = finder.read(cx);
@@ -594,7 +445,6 @@ mod tests {
         // not match anything.
         finder
             .update(cx, |f, cx| f.spawn_search("thf/".into(), cx))
-            .unwrap()
             .await;
         finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 0));
     }
@@ -633,16 +483,15 @@ mod tests {
         // Run a search that matches two files with the same relative path.
         finder
             .update(cx, |f, cx| f.spawn_search("a.t".into(), cx))
-            .unwrap()
             .await;
 
         // Can switch between different matches with the same relative path.
         finder.update(cx, |f, cx| {
             assert_eq!(f.matches.len(), 2);
             assert_eq!(f.selected_index(), 0);
-            f.select_next(&SelectNext, cx);
+            f.set_selected_index(1, cx);
             assert_eq!(f.selected_index(), 1);
-            f.select_prev(&SelectPrev, cx);
+            f.set_selected_index(0, cx);
             assert_eq!(f.selected_index(), 0);
         });
     }

crates/gpui/src/app.rs 🔗

@@ -3,7 +3,7 @@ pub mod action;
 use crate::{
     elements::ElementBox,
     executor::{self, Task},
-    keymap::{self, Keystroke},
+    keymap::{self, Binding, Keystroke},
     platform::{self, CursorStyle, Platform, PromptLevel, WindowOptions},
     presenter::Presenter,
     util::post_inc,
@@ -17,6 +17,7 @@ use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use platform::Event;
 use postage::oneshot;
+use smallvec::SmallVec;
 use smol::prelude::*;
 use std::{
     any::{type_name, Any, TypeId},
@@ -726,7 +727,7 @@ pub struct MutableAppContext {
     foreground_platform: Rc<dyn platform::ForegroundPlatform>,
     assets: Arc<AssetCache>,
     cx: AppContext,
-    action_deserializers: HashMap<&'static str, DeserializeActionCallback>,
+    action_deserializers: HashMap<&'static str, (TypeId, DeserializeActionCallback)>,
     capture_actions: HashMap<TypeId, HashMap<TypeId, Vec<Box<ActionCallback>>>>,
     actions: HashMap<TypeId, HashMap<TypeId, Vec<Box<ActionCallback>>>>,
     global_actions: HashMap<TypeId, Box<GlobalActionCallback>>,
@@ -877,7 +878,8 @@ impl MutableAppContext {
         let callback = self
             .action_deserializers
             .get(name)
-            .ok_or_else(|| anyhow!("unknown action {}", name))?;
+            .ok_or_else(|| anyhow!("unknown action {}", name))?
+            .1;
         callback(argument.unwrap_or("{}"))
             .with_context(|| format!("invalid data for action {}", name))
     }
@@ -926,7 +928,7 @@ impl MutableAppContext {
 
         self.action_deserializers
             .entry(A::qualified_name())
-            .or_insert(A::from_json_str);
+            .or_insert((TypeId::of::<A>(), A::from_json_str));
 
         let actions = if capture {
             &mut self.capture_actions
@@ -965,7 +967,7 @@ impl MutableAppContext {
 
         self.action_deserializers
             .entry(A::qualified_name())
-            .or_insert(A::from_json_str);
+            .or_insert((TypeId::of::<A>(), A::from_json_str));
 
         if self
             .global_actions
@@ -1304,6 +1306,57 @@ impl MutableAppContext {
         }
     }
 
+    pub fn available_actions(
+        &self,
+        window_id: usize,
+        view_id: usize,
+    ) -> impl Iterator<Item = (&'static str, Box<dyn Action>, SmallVec<[&Binding; 1]>)> {
+        let mut action_types: HashSet<_> = self.global_actions.keys().copied().collect();
+
+        let presenter = self
+            .presenters_and_platform_windows
+            .get(&window_id)
+            .unwrap()
+            .0
+            .clone();
+        let dispatch_path = presenter.borrow().dispatch_path_from(view_id);
+        for view_id in dispatch_path {
+            if let Some(view) = self.views.get(&(window_id, view_id)) {
+                let view_type = view.as_any().type_id();
+                if let Some(actions) = self.actions.get(&view_type) {
+                    action_types.extend(actions.keys().copied());
+                }
+            }
+        }
+
+        self.action_deserializers
+            .iter()
+            .filter_map(move |(name, (type_id, deserialize))| {
+                if action_types.contains(type_id) {
+                    Some((
+                        *name,
+                        deserialize("{}").ok()?,
+                        self.keystroke_matcher
+                            .bindings_for_action_type(*type_id)
+                            .collect(),
+                    ))
+                } else {
+                    None
+                }
+            })
+    }
+
+    pub fn dispatch_action_at(&mut self, window_id: usize, view_id: usize, action: &dyn Action) {
+        let presenter = self
+            .presenters_and_platform_windows
+            .get(&window_id)
+            .unwrap()
+            .0
+            .clone();
+        let dispatch_path = presenter.borrow().dispatch_path_from(view_id);
+        self.dispatch_action_any(window_id, &dispatch_path, action);
+    }
+
     pub fn dispatch_action<A: Action>(
         &mut self,
         window_id: usize,

crates/gpui/src/executor.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{anyhow, Result};
 use async_task::Runnable;
+use futures::channel::mpsc;
 use smol::{channel, prelude::*, Executor};
 use std::{
     any::Any,
@@ -621,17 +622,13 @@ impl Background {
         Err(async { *future.await.downcast().unwrap() })
     }
 
-    pub async fn scoped<'scope, F>(&self, scheduler: F)
+    pub async fn scoped<'scope, F>(self: &Arc<Self>, scheduler: F)
     where
         F: FnOnce(&mut Scope<'scope>),
     {
-        let mut scope = Scope {
-            futures: Default::default(),
-            _phantom: PhantomData,
-        };
+        let mut scope = Scope::new(self.clone());
         (scheduler)(&mut scope);
-        let spawned = scope
-            .futures
+        let spawned = mem::take(&mut scope.futures)
             .into_iter()
             .map(|f| self.spawn(f))
             .collect::<Vec<_>>();
@@ -668,25 +665,56 @@ impl Background {
 }
 
 pub struct Scope<'a> {
+    executor: Arc<Background>,
     futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,
+    tx: Option<mpsc::Sender<()>>,
+    rx: mpsc::Receiver<()>,
     _phantom: PhantomData<&'a ()>,
 }
 
 impl<'a> Scope<'a> {
+    fn new(executor: Arc<Background>) -> Self {
+        let (tx, rx) = mpsc::channel(1);
+        Self {
+            executor,
+            tx: Some(tx),
+            rx,
+            futures: Default::default(),
+            _phantom: PhantomData,
+        }
+    }
+
     pub fn spawn<F>(&mut self, f: F)
     where
         F: Future<Output = ()> + Send + 'a,
     {
+        let tx = self.tx.clone().unwrap();
+
+        // Safety: The 'a lifetime is guaranteed to outlive any of these futures because
+        // dropping this `Scope` blocks until all of the futures have resolved.
         let f = unsafe {
             mem::transmute::<
                 Pin<Box<dyn Future<Output = ()> + Send + 'a>>,
                 Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
-            >(Box::pin(f))
+            >(Box::pin(async move {
+                f.await;
+                drop(tx);
+            }))
         };
         self.futures.push(f);
     }
 }
 
+impl<'a> Drop for Scope<'a> {
+    fn drop(&mut self) {
+        self.tx.take().unwrap();
+
+        // Wait until the channel is closed, which means that all of the spawned
+        // futures have resolved.
+        self.executor.block(self.rx.next());
+    }
+}
+
 impl<T> Task<T> {
     pub fn ready(value: T) -> Self {
         Self::Ready(Some(value))

crates/gpui/src/keymap.rs 🔗

@@ -1,7 +1,8 @@
 use crate::Action;
 use anyhow::{anyhow, Result};
+use smallvec::SmallVec;
 use std::{
-    any::Any,
+    any::{Any, TypeId},
     collections::{HashMap, HashSet},
     fmt::Debug,
 };
@@ -23,7 +24,10 @@ struct Pending {
 }
 
 #[derive(Default)]
-pub struct Keymap(Vec<Binding>);
+pub struct Keymap {
+    bindings: Vec<Binding>,
+    binding_indices_by_action_type: HashMap<TypeId, SmallVec<[usize; 3]>>,
+}
 
 pub struct Binding {
     keystrokes: Vec<Keystroke>,
@@ -111,6 +115,10 @@ impl Matcher {
         self.keymap.clear();
     }
 
+    pub fn bindings_for_action_type(&self, action_type: TypeId) -> impl Iterator<Item = &Binding> {
+        self.keymap.bindings_for_action_type(action_type)
+    }
+
     pub fn clear_pending(&mut self) {
         self.pending.clear();
     }
@@ -132,7 +140,7 @@ impl Matcher {
         pending.keystrokes.push(keystroke);
 
         let mut retain_pending = false;
-        for binding in self.keymap.0.iter().rev() {
+        for binding in self.keymap.bindings.iter().rev() {
             if binding.keystrokes.starts_with(&pending.keystrokes)
                 && binding.context.as_ref().map(|c| c.eval(cx)).unwrap_or(true)
             {
@@ -163,15 +171,44 @@ impl Default for Matcher {
 
 impl Keymap {
     pub fn new(bindings: Vec<Binding>) -> Self {
-        Self(bindings)
+        let mut binding_indices_by_action_type = HashMap::new();
+        for (ix, binding) in bindings.iter().enumerate() {
+            binding_indices_by_action_type
+                .entry(binding.action.as_any().type_id())
+                .or_insert_with(|| SmallVec::new())
+                .push(ix);
+        }
+        Self {
+            binding_indices_by_action_type,
+            bindings,
+        }
+    }
+
+    fn bindings_for_action_type<'a>(
+        &'a self,
+        action_type: TypeId,
+    ) -> impl Iterator<Item = &'a Binding> {
+        self.binding_indices_by_action_type
+            .get(&action_type)
+            .map(SmallVec::as_slice)
+            .unwrap_or(&[])
+            .iter()
+            .map(|ix| &self.bindings[*ix])
     }
 
     fn add_bindings<T: IntoIterator<Item = Binding>>(&mut self, bindings: T) {
-        self.0.extend(bindings.into_iter());
+        for binding in bindings {
+            self.binding_indices_by_action_type
+                .entry(binding.action.as_any().type_id())
+                .or_default()
+                .push(self.bindings.len());
+            self.bindings.push(binding);
+        }
     }
 
     fn clear(&mut self) {
-        self.0.clear();
+        self.bindings.clear();
+        self.binding_indices_by_action_type.clear();
     }
 }
 
@@ -198,6 +235,10 @@ impl Binding {
             context,
         })
     }
+
+    pub fn keystrokes(&self) -> &[Keystroke] {
+        &self.keystrokes
+    }
 }
 
 impl Keystroke {
@@ -446,7 +487,7 @@ mod tests {
             a: &'static str,
         }
 
-        let keymap = Keymap(vec![
+        let keymap = Keymap::new(vec![
             Binding::new("a", A("x".to_string()), Some("a")),
             Binding::new("b", B, Some("a")),
             Binding::new("a b", Ab, Some("a || b")),

crates/gpui/src/presenter.rs 🔗

@@ -51,15 +51,21 @@ impl Presenter {
     }
 
     pub fn dispatch_path(&self, app: &AppContext) -> Vec<usize> {
+        if let Some(view_id) = app.focused_view_id(self.window_id) {
+            self.dispatch_path_from(view_id)
+        } else {
+            Vec::new()
+        }
+    }
+
+    pub(crate) fn dispatch_path_from(&self, mut view_id: usize) -> Vec<usize> {
         let mut path = Vec::new();
-        if let Some(mut view_id) = app.focused_view_id(self.window_id) {
-            path.push(view_id);
-            while let Some(parent_id) = self.parents.get(&view_id).copied() {
-                path.push(parent_id);
-                view_id = parent_id;
-            }
-            path.reverse();
+        path.push(view_id);
+        while let Some(parent_id) = self.parents.get(&view_id).copied() {
+            path.push(parent_id);
+            view_id = parent_id;
         }
+        path.reverse();
         path
     }
 

crates/outline/Cargo.toml 🔗

@@ -12,6 +12,7 @@ editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
+picker = { path = "../picker" }
 settings = { path = "../settings" }
 text = { path = "../text" }
 workspace = { path = "../workspace" }

crates/outline/src/outline.rs 🔗

@@ -4,38 +4,31 @@ use editor::{
 };
 use fuzzy::StringMatch;
 use gpui::{
-    actions, elements::*, geometry::vector::Vector2F, keymap, AppContext, Axis, Entity,
-    MutableAppContext, RenderContext, View, ViewContext, ViewHandle, WeakViewHandle,
+    actions, elements::*, geometry::vector::Vector2F, AppContext, Entity, MutableAppContext,
+    RenderContext, Task, View, ViewContext, ViewHandle,
 };
 use language::Outline;
 use ordered_float::OrderedFloat;
+use picker::{Picker, PickerDelegate};
 use settings::Settings;
 use std::cmp::{self, Reverse};
-use workspace::{
-    menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 actions!(outline, [Toggle]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(OutlineView::toggle);
-    cx.add_action(OutlineView::confirm);
-    cx.add_action(OutlineView::select_prev);
-    cx.add_action(OutlineView::select_next);
-    cx.add_action(OutlineView::select_first);
-    cx.add_action(OutlineView::select_last);
+    Picker::<OutlineView>::init(cx);
 }
 
 struct OutlineView {
-    handle: WeakViewHandle<Self>,
+    picker: ViewHandle<Picker<Self>>,
     active_editor: ViewHandle<Editor>,
     outline: Outline<Anchor>,
     selected_match_index: usize,
     prev_scroll_position: Option<Vector2F>,
     matches: Vec<StringMatch>,
-    query_editor: ViewHandle<Editor>,
-    list_state: UniformListState,
+    last_query: String,
 }
 
 pub enum Event {
@@ -55,38 +48,12 @@ impl View for OutlineView {
         "OutlineView"
     }
 
-    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let settings = cx.global::<Settings>();
-
-        Flex::new(Axis::Vertical)
-            .with_child(
-                Container::new(ChildView::new(&self.query_editor).boxed())
-                    .with_style(settings.theme.selector.input_editor.container)
-                    .boxed(),
-            )
-            .with_child(
-                FlexItem::new(self.render_matches(cx))
-                    .flex(1.0, false)
-                    .boxed(),
-            )
-            .contained()
-            .with_style(settings.theme.selector.container)
-            .constrained()
-            .with_max_width(800.0)
-            .with_max_height(1200.0)
-            .aligned()
-            .top()
-            .named("outline view")
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.query_editor);
+        cx.focus(&self.picker);
     }
 }
 
@@ -96,24 +63,16 @@ impl OutlineView {
         editor: ViewHandle<Editor>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let query_editor = cx.add_view(|cx| {
-            Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx)
-        });
-        cx.subscribe(&query_editor, Self::on_query_editor_event)
-            .detach();
-
-        let mut this = Self {
-            handle: cx.weak_handle(),
+        let handle = cx.weak_handle();
+        Self {
+            picker: cx.add_view(|cx| Picker::new(handle, cx).with_max_size(800., 1200.)),
+            last_query: Default::default(),
             matches: Default::default(),
             selected_match_index: 0,
             prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))),
             active_editor: editor,
             outline,
-            query_editor,
-            list_state: Default::default(),
-        };
-        this.update_matches(cx);
-        this
+        }
     }
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
@@ -137,34 +96,18 @@ impl OutlineView {
         }
     }
 
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if self.selected_match_index > 0 {
-            self.select(self.selected_match_index - 1, true, false, cx);
-        }
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if self.selected_match_index + 1 < self.matches.len() {
-            self.select(self.selected_match_index + 1, true, false, cx);
-        }
-    }
-
-    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
-        self.select(0, true, false, cx);
-    }
-
-    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
-        self.select(self.matches.len().saturating_sub(1), true, false, cx);
+    fn restore_active_editor(&mut self, cx: &mut MutableAppContext) {
+        self.active_editor.update(cx, |editor, cx| {
+            editor.highlight_rows(None);
+            if let Some(scroll_position) = self.prev_scroll_position {
+                editor.set_scroll_position(scroll_position, cx);
+            }
+        })
     }
 
-    fn select(&mut self, index: usize, navigate: bool, center: bool, cx: &mut ViewContext<Self>) {
-        self.selected_match_index = index;
-        self.list_state.scroll_to(if center {
-            ScrollTarget::Center(index)
-        } else {
-            ScrollTarget::Show(index)
-        });
-        if navigate {
+    fn set_selected_index(&mut self, ix: usize, navigate: bool, cx: &mut ViewContext<Self>) {
+        self.selected_match_index = ix;
+        if navigate && !self.matches.is_empty() {
             let selected_match = &self.matches[self.selected_match_index];
             let outline_item = &self.outline.items[selected_match.candidate_id];
             self.active_editor.update(cx, |active_editor, cx| {
@@ -181,27 +124,6 @@ impl OutlineView {
         cx.notify();
     }
 
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        self.prev_scroll_position.take();
-        self.active_editor.update(cx, |active_editor, cx| {
-            if let Some(rows) = active_editor.highlighted_rows() {
-                let snapshot = active_editor.snapshot(cx).display_snapshot;
-                let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot);
-                active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx);
-            }
-        });
-        cx.emit(Event::Dismissed);
-    }
-
-    fn restore_active_editor(&mut self, cx: &mut MutableAppContext) {
-        self.active_editor.update(cx, |editor, cx| {
-            editor.highlight_rows(None);
-            if let Some(scroll_position) = self.prev_scroll_position {
-                editor.set_scroll_position(scroll_position, cx);
-            }
-        })
-    }
-
     fn on_event(
         workspace: &mut Workspace,
         _: ViewHandle<Self>,
@@ -212,24 +134,27 @@ impl OutlineView {
             Event::Dismissed => workspace.dismiss_modal(cx),
         }
     }
+}
 
-    fn on_query_editor_event(
-        &mut self,
-        _: ViewHandle<Editor>,
-        event: &editor::Event,
-        cx: &mut ViewContext<Self>,
-    ) {
-        match event {
-            editor::Event::Blurred => cx.emit(Event::Dismissed),
-            editor::Event::BufferEdited { .. } => self.update_matches(cx),
-            _ => {}
-        }
+impl PickerDelegate for OutlineView {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_match_index
     }
 
-    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
+        self.set_selected_index(ix, true, cx);
+    }
+
+    fn center_selection_after_match_updates(&self) -> bool {
+        true
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
         let selected_index;
-        let navigate_to_selected_index;
-        let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
         if query.is_empty() {
             self.restore_active_editor(cx);
             self.matches = self
@@ -271,7 +196,6 @@ impl OutlineView {
                 .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
                 .map(|(ix, _, _)| ix)
                 .unwrap_or(0);
-            navigate_to_selected_index = false;
         } else {
             self.matches = smol::block_on(self.outline.search(&query, cx.background().clone()));
             selected_index = self
@@ -281,57 +205,33 @@ impl OutlineView {
                 .max_by_key(|(_, m)| OrderedFloat(m.score))
                 .map(|(ix, _)| ix)
                 .unwrap_or(0);
-            navigate_to_selected_index = !self.matches.is_empty();
         }
-        self.select(selected_index, navigate_to_selected_index, true, cx);
+        self.last_query = query;
+        self.set_selected_index(selected_index, false, cx);
+        Task::ready(())
     }
 
-    fn render_matches(&self, cx: &AppContext) -> ElementBox {
-        if self.matches.is_empty() {
-            let settings = cx.global::<Settings>();
-            return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.theme.selector.empty.label.clone(),
-                )
-                .boxed(),
-            )
-            .with_style(settings.theme.selector.empty.container)
-            .named("empty matches");
-        }
-
-        let handle = self.handle.clone();
-        let list = UniformList::new(
-            self.list_state.clone(),
-            self.matches.len(),
-            move |mut range, items, cx| {
-                let cx = cx.as_ref();
-                let view = handle.upgrade(cx).unwrap();
-                let view = view.read(cx);
-                let start = range.start;
-                range.end = cmp::min(range.end, view.matches.len());
-                items.extend(
-                    view.matches[range]
-                        .iter()
-                        .enumerate()
-                        .map(move |(ix, m)| view.render_match(m, start + ix, cx)),
-                );
-            },
-        );
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        self.prev_scroll_position.take();
+        self.active_editor.update(cx, |active_editor, cx| {
+            if let Some(rows) = active_editor.highlighted_rows() {
+                let snapshot = active_editor.snapshot(cx).display_snapshot;
+                let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot);
+                active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx);
+            }
+        });
+        cx.emit(Event::Dismissed);
+    }
 
-        Container::new(list.boxed())
-            .with_margin_top(6.0)
-            .named("matches")
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        self.restore_active_editor(cx);
+        cx.emit(Event::Dismissed);
     }
 
-    fn render_match(
-        &self,
-        string_match: &StringMatch,
-        index: usize,
-        cx: &AppContext,
-    ) -> ElementBox {
+    fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox {
         let settings = cx.global::<Settings>();
-        let style = if index == self.selected_match_index {
+        let string_match = &self.matches[ix];
+        let style = if selected {
             &settings.theme.selector.active_item
         } else {
             &settings.theme.selector.item

crates/picker/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "picker"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/picker.rs"
+doctest = false
+
+[dependencies]
+editor = { path = "../editor" }
+gpui = { path = "../gpui" }
+settings = { path = "../settings" }
+util = { path = "../util" }
+theme = { path = "../theme" }
+workspace = { path = "../workspace" }
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+serde_json = { version = "1.0.64", features = ["preserve_order"] }
+workspace = { path = "../workspace", features = ["test-support"] }
+ctor = "0.1"
+env_logger = "0.8"

crates/picker/src/picker.rs 🔗

@@ -0,0 +1,272 @@
+use editor::Editor;
+use gpui::{
+    elements::{
+        ChildView, EventHandler, Flex, Label, ParentElement, ScrollTarget, UniformList,
+        UniformListState,
+    },
+    geometry::vector::{vec2f, Vector2F},
+    keymap, AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, RenderContext, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use settings::Settings;
+use std::cmp;
+use workspace::menu::{
+    Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev,
+};
+
+pub struct Picker<D: PickerDelegate> {
+    delegate: WeakViewHandle<D>,
+    query_editor: ViewHandle<Editor>,
+    list_state: UniformListState,
+    update_task: Option<Task<()>>,
+    max_size: Vector2F,
+    confirmed: bool,
+}
+
+pub trait PickerDelegate: View {
+    fn match_count(&self) -> usize;
+    fn selected_index(&self) -> usize;
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>);
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()>;
+    fn confirm(&mut self, cx: &mut ViewContext<Self>);
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>);
+    fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox;
+    fn center_selection_after_match_updates(&self) -> bool {
+        false
+    }
+}
+
+impl<D: PickerDelegate> Entity for Picker<D> {
+    type Event = ();
+}
+
+impl<D: PickerDelegate> View for Picker<D> {
+    fn ui_name() -> &'static str {
+        "Picker"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        let settings = cx.global::<Settings>();
+        let delegate = self.delegate.clone();
+        let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
+            delegate.read(cx).match_count()
+        } else {
+            0
+        };
+
+        Flex::new(Axis::Vertical)
+            .with_child(
+                ChildView::new(&self.query_editor)
+                    .contained()
+                    .with_style(settings.theme.selector.input_editor.container)
+                    .boxed(),
+            )
+            .with_child(
+                if match_count == 0 {
+                    Label::new(
+                        "No matches".into(),
+                        settings.theme.selector.empty.label.clone(),
+                    )
+                    .contained()
+                    .with_style(settings.theme.selector.empty.container)
+                } else {
+                    UniformList::new(
+                        self.list_state.clone(),
+                        match_count,
+                        move |mut range, items, cx| {
+                            let cx = cx.as_ref();
+                            let delegate = delegate.upgrade(cx).unwrap();
+                            let delegate = delegate.read(cx);
+                            let selected_ix = delegate.selected_index();
+                            range.end = cmp::min(range.end, delegate.match_count());
+                            items.extend(range.map(move |ix| {
+                                EventHandler::new(delegate.render_match(ix, ix == selected_ix, cx))
+                                    .on_mouse_down(move |cx| {
+                                        cx.dispatch_action(SelectIndex(ix));
+                                        true
+                                    })
+                                    .boxed()
+                            }));
+                        },
+                    )
+                    .contained()
+                    .with_margin_top(6.0)
+                }
+                .flex(1., false)
+                .boxed(),
+            )
+            .contained()
+            .with_style(settings.theme.selector.container)
+            .constrained()
+            .with_max_width(self.max_size.x())
+            .with_max_height(self.max_size.y())
+            .aligned()
+            .top()
+            .named("picker")
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.query_editor);
+    }
+}
+
+impl<D: PickerDelegate> Picker<D> {
+    pub fn init(cx: &mut MutableAppContext) {
+        cx.add_action(Self::select_first);
+        cx.add_action(Self::select_last);
+        cx.add_action(Self::select_next);
+        cx.add_action(Self::select_prev);
+        cx.add_action(Self::select_index);
+        cx.add_action(Self::confirm);
+        cx.add_action(Self::cancel);
+    }
+
+    pub fn new(delegate: WeakViewHandle<D>, cx: &mut ViewContext<Self>) -> Self {
+        let query_editor = cx.add_view(|cx| {
+            Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx)
+        });
+        cx.subscribe(&query_editor, Self::on_query_editor_event)
+            .detach();
+        let this = Self {
+            query_editor,
+            list_state: Default::default(),
+            update_task: None,
+            delegate,
+            max_size: vec2f(500., 420.),
+            confirmed: false,
+        };
+        cx.defer(|this, cx| this.update_matches(cx));
+        this
+    }
+
+    pub fn with_max_size(mut self, width: f32, height: f32) -> Self {
+        self.max_size = vec2f(width, height);
+        self
+    }
+
+    fn on_query_editor_event(
+        &mut self,
+        _: ViewHandle<Editor>,
+        event: &editor::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            editor::Event::BufferEdited { .. } => self.update_matches(cx),
+            editor::Event::Blurred if !self.confirmed => {
+                if let Some(delegate) = self.delegate.upgrade(cx) {
+                    delegate.update(cx, |delegate, cx| {
+                        delegate.dismiss(cx);
+                    })
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let query = self.query_editor.read(cx).text(cx);
+            let update = delegate.update(cx, |d, cx| d.update_matches(query, cx));
+            cx.notify();
+            self.update_task = Some(cx.spawn(|this, mut cx| async move {
+                update.await;
+                this.update(&mut cx, |this, cx| {
+                    if let Some(delegate) = this.delegate.upgrade(cx) {
+                        let delegate = delegate.read(cx);
+                        let index = delegate.selected_index();
+                        let target = if delegate.center_selection_after_match_updates() {
+                            ScrollTarget::Center(index)
+                        } else {
+                            ScrollTarget::Show(index)
+                        };
+                        this.list_state.scroll_to(target);
+                        cx.notify();
+                        this.update_task.take();
+                    }
+                });
+            }));
+        }
+    }
+
+    pub fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let index = 0;
+            delegate.update(cx, |delegate, cx| delegate.set_selected_index(0, cx));
+            self.list_state.scroll_to(ScrollTarget::Show(index));
+            cx.notify();
+        }
+    }
+
+    pub fn select_index(&mut self, action: &SelectIndex, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let index = action.0;
+            self.confirmed = true;
+            delegate.update(cx, |delegate, cx| {
+                delegate.set_selected_index(index, cx);
+                delegate.confirm(cx);
+            });
+        }
+    }
+
+    pub fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let index = delegate.update(cx, |delegate, cx| {
+                let match_count = delegate.match_count();
+                let index = if match_count > 0 { match_count - 1 } else { 0 };
+                delegate.set_selected_index(index, cx);
+                index
+            });
+            self.list_state.scroll_to(ScrollTarget::Show(index));
+            cx.notify();
+        }
+    }
+
+    pub fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let index = delegate.update(cx, |delegate, cx| {
+                let mut selected_index = delegate.selected_index();
+                if selected_index + 1 < delegate.match_count() {
+                    selected_index += 1;
+                    delegate.set_selected_index(selected_index, cx);
+                }
+                selected_index
+            });
+            self.list_state.scroll_to(ScrollTarget::Show(index));
+            cx.notify();
+        }
+    }
+
+    pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            let index = delegate.update(cx, |delegate, cx| {
+                let mut selected_index = delegate.selected_index();
+                if selected_index > 0 {
+                    selected_index -= 1;
+                    delegate.set_selected_index(selected_index, cx);
+                }
+                selected_index
+            });
+            self.list_state.scroll_to(ScrollTarget::Show(index));
+            cx.notify();
+        }
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            self.confirmed = true;
+            delegate.update(cx, |delegate, cx| delegate.confirm(cx));
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        if let Some(delegate) = self.delegate.upgrade(cx) {
+            delegate.update(cx, |delegate, cx| delegate.dismiss(cx));
+        }
+    }
+}

crates/project_symbols/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+picker = { path = "../picker" }
 project = { path = "../project" }
 text = { path = "../text" }
 settings = { path = "../settings" }

crates/project_symbols/src/project_symbols.rs 🔗

@@ -3,43 +3,32 @@ use editor::{
 };
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, elements::*, keymap, AppContext, Axis, Entity, ModelHandle, MutableAppContext,
-    RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task,
+    View, ViewContext, ViewHandle,
 };
 use ordered_float::OrderedFloat;
+use picker::{Picker, PickerDelegate};
 use project::{Project, Symbol};
 use settings::Settings;
-use std::{
-    borrow::Cow,
-    cmp::{self, Reverse},
-};
+use std::{borrow::Cow, cmp::Reverse};
 use util::ResultExt;
-use workspace::{
-    menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 actions!(project_symbols, [Toggle]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ProjectSymbolsView::toggle);
-    cx.add_action(ProjectSymbolsView::confirm);
-    cx.add_action(ProjectSymbolsView::select_prev);
-    cx.add_action(ProjectSymbolsView::select_next);
-    cx.add_action(ProjectSymbolsView::select_first);
-    cx.add_action(ProjectSymbolsView::select_last);
+    Picker::<ProjectSymbolsView>::init(cx);
 }
 
 pub struct ProjectSymbolsView {
-    handle: WeakViewHandle<Self>,
+    picker: ViewHandle<Picker<Self>>,
     project: ModelHandle<Project>,
     selected_match_index: usize,
-    list_state: UniformListState,
     symbols: Vec<Symbol>,
     match_candidates: Vec<StringMatchCandidate>,
+    show_worktree_root_name: bool,
     matches: Vec<StringMatch>,
-    pending_symbols_task: Task<Option<()>>,
-    query_editor: ViewHandle<Editor>,
 }
 
 pub enum Event {
@@ -56,60 +45,27 @@ impl View for ProjectSymbolsView {
         "ProjectSymbolsView"
     }
 
-    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let settings = cx.global::<Settings>();
-        Flex::new(Axis::Vertical)
-            .with_child(
-                Container::new(ChildView::new(&self.query_editor).boxed())
-                    .with_style(settings.theme.selector.input_editor.container)
-                    .boxed(),
-            )
-            .with_child(
-                FlexItem::new(self.render_matches(cx))
-                    .flex(1., false)
-                    .boxed(),
-            )
-            .contained()
-            .with_style(settings.theme.selector.container)
-            .constrained()
-            .with_max_width(500.0)
-            .with_max_height(420.0)
-            .aligned()
-            .top()
-            .named("project symbols view")
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.query_editor);
+        cx.focus(&self.picker);
     }
 }
 
 impl ProjectSymbolsView {
     fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
-        let query_editor = cx.add_view(|cx| {
-            Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx)
-        });
-        cx.subscribe(&query_editor, Self::on_query_editor_event)
-            .detach();
-        let mut this = Self {
-            handle: cx.weak_handle(),
+        let handle = cx.weak_handle();
+        Self {
             project,
+            picker: cx.add_view(|cx| Picker::new(handle, cx)),
             selected_match_index: 0,
-            list_state: Default::default(),
             symbols: Default::default(),
             match_candidates: Default::default(),
             matches: Default::default(),
-            pending_symbols_task: Task::ready(None),
-            query_editor,
-        };
-        this.update_matches(cx);
-        this
+            show_worktree_root_name: false,
+        }
     }
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
@@ -121,72 +77,7 @@ impl ProjectSymbolsView {
         });
     }
 
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if self.selected_match_index > 0 {
-            self.select(self.selected_match_index - 1, cx);
-        }
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if self.selected_match_index + 1 < self.matches.len() {
-            self.select(self.selected_match_index + 1, cx);
-        }
-    }
-
-    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
-        self.select(0, cx);
-    }
-
-    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
-        self.select(self.matches.len().saturating_sub(1), cx);
-    }
-
-    fn select(&mut self, index: usize, cx: &mut ViewContext<Self>) {
-        self.selected_match_index = index;
-        self.list_state.scroll_to(ScrollTarget::Show(index));
-        cx.notify();
-    }
-
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        if let Some(symbol) = self
-            .matches
-            .get(self.selected_match_index)
-            .map(|mat| self.symbols[mat.candidate_id].clone())
-        {
-            cx.emit(Event::Selected(symbol));
-        }
-    }
-
-    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
-        self.filter(cx);
-        let query = self.query_editor.read(cx).text(cx);
-        let symbols = self
-            .project
-            .update(cx, |project, cx| project.symbols(&query, cx));
-        self.pending_symbols_task = cx.spawn_weak(|this, mut cx| async move {
-            let symbols = symbols.await.log_err()?;
-            if let Some(this) = this.upgrade(&cx) {
-                this.update(&mut cx, |this, cx| {
-                    this.match_candidates = symbols
-                        .iter()
-                        .enumerate()
-                        .map(|(id, symbol)| {
-                            StringMatchCandidate::new(
-                                id,
-                                symbol.label.text[symbol.label.filter_range.clone()].to_string(),
-                            )
-                        })
-                        .collect();
-                    this.symbols = symbols;
-                    this.filter(cx);
-                });
-            }
-            None
-        });
-    }
-
-    fn filter(&mut self, cx: &mut ViewContext<Self>) {
-        let query = self.query_editor.read(cx).text(cx);
+    fn filter(&mut self, query: &str, cx: &mut ViewContext<Self>) {
         let mut matches = if query.is_empty() {
             self.match_candidates
                 .iter()
@@ -201,7 +92,7 @@ impl ProjectSymbolsView {
         } else {
             smol::block_on(fuzzy::match_strings(
                 &self.match_candidates,
-                &query,
+                query,
                 false,
                 100,
                 &Default::default(),
@@ -225,57 +116,111 @@ impl ProjectSymbolsView {
         }
 
         self.matches = matches;
-        self.select_first(&SelectFirst, cx);
+        self.set_selected_index(0, cx);
         cx.notify();
     }
 
-    fn render_matches(&self, cx: &AppContext) -> ElementBox {
-        if self.matches.is_empty() {
-            let settings = cx.global::<Settings>();
-            return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.theme.selector.empty.label.clone(),
-                )
-                .boxed(),
-            )
-            .with_style(settings.theme.selector.empty.container)
-            .named("empty matches");
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => workspace.dismiss_modal(cx),
+            Event::Selected(symbol) => {
+                let buffer = workspace
+                    .project()
+                    .update(cx, |project, cx| project.open_buffer_for_symbol(symbol, cx));
+
+                let symbol = symbol.clone();
+                cx.spawn(|workspace, mut cx| async move {
+                    let buffer = buffer.await?;
+                    workspace.update(&mut cx, |workspace, cx| {
+                        let position = buffer
+                            .read(cx)
+                            .clip_point_utf16(symbol.range.start, Bias::Left);
+
+                        let editor = workspace.open_project_item::<Editor>(buffer, cx);
+                        editor.update(cx, |editor, cx| {
+                            editor.select_ranges(
+                                [position..position],
+                                Some(Autoscroll::Center),
+                                cx,
+                            );
+                        });
+                    });
+                    Ok::<_, anyhow::Error>(())
+                })
+                .detach_and_log_err(cx);
+                workspace.dismiss_modal(cx);
+            }
         }
+    }
+}
 
-        let handle = self.handle.clone();
-        let list = UniformList::new(
-            self.list_state.clone(),
-            self.matches.len(),
-            move |mut range, items, cx| {
-                let cx = cx.as_ref();
-                let view = handle.upgrade(cx).unwrap();
-                let view = view.read(cx);
-                let start = range.start;
-                range.end = cmp::min(range.end, view.matches.len());
+impl PickerDelegate for ProjectSymbolsView {
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(symbol) = self
+            .matches
+            .get(self.selected_match_index)
+            .map(|mat| self.symbols[mat.candidate_id].clone())
+        {
+            cx.emit(Event::Selected(symbol));
+        }
+    }
 
-                let show_worktree_root_name =
-                    view.project.read(cx).visible_worktrees(cx).count() > 1;
-                items.extend(view.matches[range].iter().enumerate().map(move |(ix, m)| {
-                    view.render_match(m, start + ix, show_worktree_root_name, cx)
-                }));
-            },
-        );
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
 
-        Container::new(list.boxed())
-            .with_margin_top(6.0)
-            .named("matches")
+    fn match_count(&self) -> usize {
+        self.matches.len()
     }
 
-    fn render_match(
-        &self,
-        string_match: &StringMatch,
-        index: usize,
-        show_worktree_root_name: bool,
-        cx: &AppContext,
-    ) -> ElementBox {
+    fn selected_index(&self) -> usize {
+        self.selected_match_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
+        self.selected_match_index = ix;
+        cx.notify();
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        self.filter(&query, cx);
+        self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1;
+        let symbols = self
+            .project
+            .update(cx, |project, cx| project.symbols(&query, cx));
+        cx.spawn_weak(|this, mut cx| async move {
+            let symbols = symbols.await.log_err();
+            if let Some(this) = this.upgrade(&cx) {
+                if let Some(symbols) = symbols {
+                    this.update(&mut cx, |this, cx| {
+                        this.match_candidates = symbols
+                            .iter()
+                            .enumerate()
+                            .map(|(id, symbol)| {
+                                StringMatchCandidate::new(
+                                    id,
+                                    symbol.label.text[symbol.label.filter_range.clone()]
+                                        .to_string(),
+                                )
+                            })
+                            .collect();
+                        this.symbols = symbols;
+                        this.filter(&query, cx);
+                    });
+                }
+            }
+        })
+    }
+
+    fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox {
+        let string_match = &self.matches[ix];
         let settings = cx.global::<Settings>();
-        let style = if index == self.selected_match_index {
+        let style = if selected {
             &settings.theme.selector.active_item
         } else {
             &settings.theme.selector.item
@@ -284,7 +229,7 @@ impl ProjectSymbolsView {
         let syntax_runs = styled_runs_for_code_label(&symbol.label, &settings.theme.editor.syntax);
 
         let mut path = symbol.path.to_string_lossy();
-        if show_worktree_root_name {
+        if self.show_worktree_root_name {
             let project = self.project.read(cx);
             if let Some(worktree) = project.worktree_for_id(symbol.worktree_id, cx) {
                 path = Cow::Owned(format!(
@@ -317,55 +262,4 @@ impl ProjectSymbolsView {
             .with_style(style.container)
             .boxed()
     }
-
-    fn on_query_editor_event(
-        &mut self,
-        _: ViewHandle<Editor>,
-        event: &editor::Event,
-        cx: &mut ViewContext<Self>,
-    ) {
-        match event {
-            editor::Event::Blurred => cx.emit(Event::Dismissed),
-            editor::Event::BufferEdited { .. } => self.update_matches(cx),
-            _ => {}
-        }
-    }
-
-    fn on_event(
-        workspace: &mut Workspace,
-        _: ViewHandle<Self>,
-        event: &Event,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        match event {
-            Event::Dismissed => workspace.dismiss_modal(cx),
-            Event::Selected(symbol) => {
-                let buffer = workspace
-                    .project()
-                    .update(cx, |project, cx| project.open_buffer_for_symbol(symbol, cx));
-
-                let symbol = symbol.clone();
-                cx.spawn(|workspace, mut cx| async move {
-                    let buffer = buffer.await?;
-                    workspace.update(&mut cx, |workspace, cx| {
-                        let position = buffer
-                            .read(cx)
-                            .clip_point_utf16(symbol.range.start, Bias::Left);
-
-                        let editor = workspace.open_project_item::<Editor>(buffer, cx);
-                        editor.update(cx, |editor, cx| {
-                            editor.select_ranges(
-                                [position..position],
-                                Some(Autoscroll::Center),
-                                cx,
-                            );
-                        });
-                    });
-                    Ok::<_, anyhow::Error>(())
-                })
-                .detach_and_log_err(cx);
-                workspace.dismiss_modal(cx);
-            }
-        }
-    }
 }

crates/theme/src/theme.rs 🔗

@@ -21,6 +21,7 @@ pub struct Theme {
     pub chat_panel: ChatPanel,
     pub contacts_panel: ContactsPanel,
     pub project_panel: ProjectPanel,
+    pub command_palette: CommandPalette,
     pub selector: Selector,
     pub editor: Editor,
     pub search: Search,
@@ -187,6 +188,12 @@ pub struct ProjectPanelEntry {
     pub icon_spacing: f32,
 }
 
+#[derive(Debug, Deserialize, Default)]
+pub struct CommandPalette {
+    pub key: ContainedLabel,
+    pub keystroke_spacing: f32,
+}
+
 #[derive(Deserialize, Default)]
 pub struct ContactsPanel {
     #[serde(flatten)]
@@ -259,7 +266,7 @@ pub struct ContainedText {
     pub text: TextStyle,
 }
 
-#[derive(Clone, Deserialize, Default)]
+#[derive(Clone, Debug, Deserialize, Default)]
 pub struct ContainedLabel {
     #[serde(flatten)]
     pub container: ContainerStyle,

crates/theme_selector/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+picker = { path = "../picker" }
 theme = { path = "../theme" }
 settings = { path = "../settings" }
 workspace = { path = "../workspace" }

crates/theme_selector/src/theme_selector.rs 🔗

@@ -1,35 +1,30 @@
-use editor::Editor;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, elements::*, keymap, AppContext, Axis, Element, ElementBox, Entity, MutableAppContext,
+    actions, elements::*, AppContext, Element, ElementBox, Entity, MutableAppContext,
     RenderContext, View, ViewContext, ViewHandle,
 };
+use picker::{Picker, PickerDelegate};
 use settings::Settings;
-use std::{cmp, sync::Arc};
+use std::sync::Arc;
 use theme::{Theme, ThemeRegistry};
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 pub struct ThemeSelector {
-    themes: Arc<ThemeRegistry>,
+    registry: Arc<ThemeRegistry>,
+    theme_names: Vec<String>,
     matches: Vec<StringMatch>,
-    query_editor: ViewHandle<Editor>,
-    list_state: UniformListState,
-    selected_index: usize,
     original_theme: Arc<Theme>,
+    picker: ViewHandle<Picker<Self>>,
     selection_completed: bool,
+    selected_index: usize,
 }
 
 actions!(theme_selector, [Toggle, Reload]);
 
 pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(ThemeSelector::confirm);
-    cx.add_action(ThemeSelector::select_prev);
-    cx.add_action(ThemeSelector::select_next);
     cx.add_action(ThemeSelector::toggle);
     cx.add_action(ThemeSelector::reload);
+    Picker::<ThemeSelector>::init(cx);
 }
 
 pub enum Event {
@@ -38,38 +33,38 @@ pub enum Event {
 
 impl ThemeSelector {
     fn new(registry: Arc<ThemeRegistry>, cx: &mut ViewContext<Self>) -> Self {
-        let query_editor = cx.add_view(|cx| {
-            Editor::single_line(Some(|theme| theme.selector.input_editor.clone()), cx)
-        });
-
-        cx.subscribe(&query_editor, Self::on_query_editor_event)
-            .detach();
-
+        let handle = cx.weak_handle();
+        let picker = cx.add_view(|cx| Picker::new(handle, cx));
         let original_theme = cx.global::<Settings>().theme.clone();
-
+        let theme_names = registry.list().collect::<Vec<_>>();
+        let matches = theme_names
+            .iter()
+            .map(|name| StringMatch {
+                candidate_id: 0,
+                score: 0.0,
+                positions: Default::default(),
+                string: name.clone(),
+            })
+            .collect();
         let mut this = Self {
-            themes: registry,
-            query_editor,
-            matches: Vec::new(),
-            list_state: Default::default(),
-            selected_index: 0, // Default index for now
+            registry,
+            theme_names,
+            matches,
+            picker,
             original_theme: original_theme.clone(),
+            selected_index: 0,
             selection_completed: false,
         };
-        this.update_matches(cx);
-
-        // Set selected index to current theme
         this.select_if_matching(&original_theme.name);
-
         this
     }
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
         let themes = workspace.themes();
         workspace.toggle_modal(cx, |cx, _| {
-            let selector = cx.add_view(|cx| Self::new(themes, cx));
-            cx.subscribe(&selector, Self::on_event).detach();
-            selector
+            let this = cx.add_view(|cx| Self::new(themes, cx));
+            cx.subscribe(&this, Self::on_event).detach();
+            this
         });
     }
 
@@ -88,36 +83,9 @@ impl ThemeSelector {
         }
     }
 
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        self.selection_completed = true;
-        cx.emit(Event::Dismissed);
-    }
-
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if self.selected_index > 0 {
-            self.selected_index -= 1;
-        }
-        self.list_state
-            .scroll_to(ScrollTarget::Show(self.selected_index));
-
-        self.show_selected_theme(cx);
-        cx.notify();
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if self.selected_index + 1 < self.matches.len() {
-            self.selected_index += 1;
-        }
-        self.list_state
-            .scroll_to(ScrollTarget::Show(self.selected_index));
-
-        self.show_selected_theme(cx);
-        cx.notify();
-    }
-
     fn show_selected_theme(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(mat) = self.matches.get(self.selected_index) {
-            match self.themes.get(&mat.string) {
+            match self.registry.get(&mat.string) {
                 Ok(theme) => Self::set_theme(theme, cx),
                 Err(error) => {
                     log::error!("error loading theme {}: {}", mat.string, error)
@@ -134,49 +102,6 @@ impl ThemeSelector {
             .unwrap_or(self.selected_index);
     }
 
-    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
-        let background = cx.background().clone();
-        let candidates = self
-            .themes
-            .list()
-            .enumerate()
-            .map(|(id, name)| StringMatchCandidate {
-                id,
-                char_bag: name.as_str().into(),
-                string: name,
-            })
-            .collect::<Vec<_>>();
-        let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
-
-        self.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 {
-            smol::block_on(match_strings(
-                &candidates,
-                &query,
-                false,
-                100,
-                &Default::default(),
-                background,
-            ))
-        };
-
-        self.selected_index = self
-            .selected_index
-            .min(self.matches.len().saturating_sub(1));
-
-        cx.notify();
-    }
-
     fn on_event(
         workspace: &mut Workspace,
         _: ViewHandle<ThemeSelector>,
@@ -190,89 +115,104 @@ impl ThemeSelector {
         }
     }
 
-    fn on_query_editor_event(
-        &mut self,
-        _: ViewHandle<Editor>,
-        event: &editor::Event,
-        cx: &mut ViewContext<Self>,
-    ) {
-        match event {
-            editor::Event::BufferEdited { .. } => {
-                self.update_matches(cx);
-                self.select_if_matching(&cx.global::<Settings>().theme.name);
-                self.show_selected_theme(cx);
-            }
-            editor::Event::Blurred => cx.emit(Event::Dismissed),
-            _ => {}
-        }
+    fn set_theme(theme: Arc<Theme>, cx: &mut MutableAppContext) {
+        cx.update_global::<Settings, _, _>(|settings, cx| {
+            settings.theme = theme;
+            cx.refresh_windows();
+        });
     }
+}
 
-    fn render_matches(&self, cx: &mut RenderContext<Self>) -> ElementBox {
-        if self.matches.is_empty() {
-            let settings = cx.global::<Settings>();
-            return Container::new(
-                Label::new(
-                    "No matches".into(),
-                    settings.theme.selector.empty.label.clone(),
-                )
-                .boxed(),
-            )
-            .with_style(settings.theme.selector.empty.container)
-            .named("empty matches");
+impl PickerDelegate for ThemeSelector {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        self.selection_completed = true;
+        cx.emit(Event::Dismissed);
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.selection_completed {
+            Self::set_theme(self.original_theme.clone(), cx);
+            self.selection_completed = true;
         }
+        cx.emit(Event::Dismissed);
+    }
 
-        let handle = cx.handle();
-        let list =
-            UniformList::new(
-                self.list_state.clone(),
-                self.matches.len(),
-                move |mut range, items, cx| {
-                    let cx = cx.as_ref();
-                    let selector = handle.upgrade(cx).unwrap();
-                    let selector = selector.read(cx);
-                    let start = range.start;
-                    range.end = cmp::min(range.end, selector.matches.len());
-                    items.extend(selector.matches[range].iter().enumerate().map(
-                        move |(i, path_match)| selector.render_match(path_match, start + i, cx),
-                    ));
-                },
-            );
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
 
-        Container::new(list.boxed())
-            .with_margin_top(6.0)
-            .named("matches")
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
+        self.selected_index = ix;
+        self.show_selected_theme(cx);
     }
 
-    fn render_match(&self, theme_match: &StringMatch, index: usize, cx: &AppContext) -> ElementBox {
-        let settings = cx.global::<Settings>();
-        let theme = &settings.theme;
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> gpui::Task<()> {
+        let background = cx.background().clone();
+        let candidates = self
+            .theme_names
+            .iter()
+            .enumerate()
+            .map(|(id, name)| StringMatchCandidate {
+                id,
+                char_bag: name.as_str().into(),
+                string: name.clone(),
+            })
+            .collect::<Vec<_>>();
 
-        let container = Container::new(
-            Label::new(
-                theme_match.string.clone(),
-                if index == self.selected_index {
-                    theme.selector.active_item.label.clone()
-                } else {
-                    theme.selector.item.label.clone()
-                },
-            )
-            .with_highlights(theme_match.positions.clone())
-            .boxed(),
-        )
-        .with_style(if index == self.selected_index {
-            theme.selector.active_item.container
-        } else {
-            theme.selector.item.container
-        });
+        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
+            };
 
-        container.boxed()
+            this.update(&mut cx, |this, cx| {
+                this.matches = matches;
+                this.selected_index = this
+                    .selected_index
+                    .min(this.matches.len().saturating_sub(1));
+                this.show_selected_theme(cx);
+                cx.notify();
+            });
+        })
     }
 
-    fn set_theme(theme: Arc<Theme>, cx: &mut MutableAppContext) {
-        cx.update_global::<Settings, _, _>(|settings, cx| {
-            settings.theme = theme;
-            cx.refresh_windows();
-        });
+    fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox {
+        let settings = cx.global::<Settings>();
+        let theme = &settings.theme;
+        let theme_match = &self.matches[ix];
+        let style = if selected {
+            &theme.selector.active_item
+        } else {
+            &theme.selector.item
+        };
+
+        Label::new(theme_match.string.clone(), style.label.clone())
+            .with_highlights(theme_match.positions.clone())
+            .contained()
+            .with_style(style.container)
+            .boxed()
     }
 }
 
@@ -291,43 +231,11 @@ impl View for ThemeSelector {
         "ThemeSelector"
     }
 
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        Align::new(
-            ConstrainedBox::new(
-                Container::new(
-                    Flex::new(Axis::Vertical)
-                        .with_child(
-                            ChildView::new(&self.query_editor)
-                                .contained()
-                                .with_style(theme.selector.input_editor.container)
-                                .boxed(),
-                        )
-                        .with_child(
-                            FlexItem::new(self.render_matches(cx))
-                                .flex(1., false)
-                                .boxed(),
-                        )
-                        .boxed(),
-                )
-                .with_style(theme.selector.container)
-                .boxed(),
-            )
-            .with_max_width(600.0)
-            .with_max_height(400.0)
-            .boxed(),
-        )
-        .top()
-        .named("theme selector")
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.query_editor);
-    }
-
-    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
+        cx.focus(&self.picker);
     }
 }

crates/workspace/src/menu.rs 🔗

@@ -1,4 +1,16 @@
+#[derive(Clone)]
+pub struct SelectIndex(pub usize);
+
 gpui::actions!(
     menu,
-    [Confirm, SelectPrev, SelectNext, SelectFirst, SelectLast]
+    [
+        Cancel,
+        Confirm,
+        SelectPrev,
+        SelectNext,
+        SelectFirst,
+        SelectLast
+    ]
 );
+
+gpui::impl_internal_actions!(menu, [SelectIndex]);

crates/zed/Cargo.toml 🔗

@@ -33,6 +33,7 @@ assets = { path = "../assets" }
 breadcrumbs = { path = "../breadcrumbs" }
 chat_panel = { path = "../chat_panel" }
 collections = { path = "../collections" }
+command_palette = { path = "../command_palette" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 contacts_panel = { path = "../contacts_panel" }
@@ -64,7 +65,6 @@ anyhow = "1.0.38"
 async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] }
 async-recursion = "0.3"
 async-trait = "0.1"
-crossbeam-channel = "0.5.0"
 ctor = "0.1.20"
 dirs = "3.0"
 easy-parallel = "3.1.0"

crates/zed/src/main.rs 🔗

@@ -98,6 +98,7 @@ fn main() {
         project::Project::init(&client);
         client::Channel::init(&client);
         client::init(client.clone(), cx);
+        command_palette::init(cx);
         workspace::init(&client, cx);
         editor::init(cx);
         go_to_line::init(cx);

styles/src/styleTree/app.ts 🔗

@@ -2,6 +2,7 @@ import Theme from "../themes/theme";
 import chatPanel from "./chatPanel";
 import { text } from "./components";
 import contactsPanel from "./contactsPanel";
+import commandPalette from "./commandPalette";
 import editor from "./editor";
 import projectPanel from "./projectPanel";
 import search from "./search";
@@ -29,6 +30,7 @@ export default function app(theme: Theme): Object {
         },
       },
     },
+    commandPalette: commandPalette(theme),
     projectPanel: projectPanel(theme),
     chatPanel: chatPanel(theme),
     contactsPanel: contactsPanel(theme),

styles/src/styleTree/commandPalette.ts 🔗

@@ -0,0 +1,21 @@
+import Theme from "../themes/theme";
+import { text, backgroundColor, border } from "./components";
+
+export default function commandPalette(theme: Theme) {
+  return {
+    keystrokeSpacing: 8,
+    key: {
+      text: text(theme, "mono", "primary", { size: "xs" }),
+      cornerRadius: 3,
+      background: backgroundColor(theme, "info", "base"),
+      border: border(theme, "info"),
+      padding: {
+        left: 3,
+        right: 3,
+      },
+      margin: {
+        left: 3
+      },
+    }
+  }
+}

styles/src/styleTree/selectorModal.ts 🔗

@@ -38,7 +38,7 @@ export default function selectorModal(theme: Theme): Object {
     },
     inputEditor: {
       background: backgroundColor(theme, 500),
-      corner_radius: 6,
+      cornerRadius: 6,
       placeholderText: text(theme, "sans", "placeholder"),
       selection: player(theme, 1).selection,
       text: text(theme, "mono", "primary"),