command palette2 (#3296)

Conrad Irwin created

Still to do:
- [ ] Figure out why `ctrl-g` `ctrl-g` doesn't dismiss GoToLine (but
`cmd-shift-p` `cmd-shift-p` does dismiss Command). Likely to do with the
"Editor" context in the keymap, but this did used to work in gpui1.
- [ ] Fix first render with incorrect matches immediately flashes.
- [ ] Show keybindings
- [x] Figure out why `cmd-shift-p "go to line" enter` panics
- [ ] Fix the selected visuals
- [ ] Party hard
- [ ] Uncomment the tests

Change summary

Cargo.lock                                     |  35 +
Cargo.toml                                     |   4 
crates/command_palette2/Cargo.toml             |  34 +
crates/command_palette2/src/command_palette.rs | 534 ++++++++++++++++++++
crates/go_to_line2/src/go_to_line.rs           |  45 
crates/gpui2/src/action.rs                     |  33 +
crates/gpui2/src/elements/uniform_list.rs      |  85 ++
crates/gpui2/src/interactive.rs                |   6 
crates/gpui2/src/view.rs                       |   4 
crates/gpui2/src/window.rs                     |  96 +++
crates/picker2/Cargo.toml                      |   1 
crates/picker2/src/picker2.rs                  |  50 +
crates/workspace2/src/modal_layer.rs           | 163 ++---
crates/workspace2/src/workspace2.rs            | 349 +++++++-----
crates/zed2/Cargo.toml                         |   4 
crates/zed2/src/main.rs                        |   4 
crates/zed_actions2/Cargo.toml                 |  11 
crates/zed_actions2/src/lib.rs                 |  34 +
18 files changed, 1,170 insertions(+), 322 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1880,6 +1880,30 @@ dependencies = [
  "zed-actions",
 ]
 
+[[package]]
+name = "command_palette2"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "ctor",
+ "editor2",
+ "env_logger 0.9.3",
+ "fuzzy2",
+ "gpui2",
+ "language2",
+ "picker2",
+ "project2",
+ "serde",
+ "serde_json",
+ "settings2",
+ "theme2",
+ "ui2",
+ "util",
+ "workspace2",
+ "zed_actions2",
+]
+
 [[package]]
 name = "component_test"
 version = "0.1.0"
@@ -6135,6 +6159,7 @@ dependencies = [
  "serde_json",
  "settings2",
  "theme2",
+ "ui2",
  "util",
 ]
 
@@ -11371,6 +11396,7 @@ dependencies = [
  "cli",
  "client2",
  "collections",
+ "command_palette2",
  "copilot2",
  "ctor",
  "db2",
@@ -11457,6 +11483,15 @@ dependencies = [
  "util",
  "uuid 1.4.1",
  "workspace2",
+ "zed_actions2",
+]
+
+[[package]]
+name = "zed_actions2"
+version = "0.1.0"
+dependencies = [
+ "gpui2",
+ "serde",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -20,6 +20,7 @@ members = [
     "crates/collab_ui",
     "crates/collections",
     "crates/command_palette",
+    "crates/command_palette2",
     "crates/component_test",
     "crates/context_menu",
     "crates/copilot",
@@ -110,7 +111,8 @@ members = [
     "crates/xtask",
     "crates/zed",
     "crates/zed2",
-    "crates/zed-actions"
+    "crates/zed-actions",
+    "crates/zed_actions2"
 ]
 default-members = ["crates/zed"]
 resolver = "2"

crates/command_palette2/Cargo.toml 🔗

@@ -0,0 +1,34 @@
+[package]
+name = "command_palette2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/command_palette.rs"
+doctest = false
+
+[dependencies]
+collections = { path = "../collections" }
+editor = { package = "editor2", path = "../editor2" }
+fuzzy = { package = "fuzzy2", path = "../fuzzy2" }
+gpui = { package = "gpui2", path = "../gpui2" }
+picker = { package = "picker2", path = "../picker2" }
+project = { package = "project2", path = "../project2" }
+settings = { package = "settings2", path = "../settings2" }
+ui = { package = "ui2", path = "../ui2" }
+util = { path = "../util" }
+theme = { package = "theme2", path = "../theme2" }
+workspace = { package="workspace2", path = "../workspace2" }
+zed_actions = { package = "zed_actions2", path = "../zed_actions2" }
+anyhow.workspace = true
+serde.workspace = true
+[dev-dependencies]
+gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] }
+editor = { package = "editor2", path = "../editor2", features = ["test-support"] }
+language = { package="language2", path = "../language2", features = ["test-support"] }
+project = { package="project2", path = "../project2", features = ["test-support"] }
+serde_json.workspace = true
+workspace = { package="workspace2", path = "../workspace2", features = ["test-support"] }
+ctor.workspace = true
+env_logger.workspace = true

crates/command_palette2/src/command_palette.rs 🔗

@@ -0,0 +1,534 @@
+use collections::{CommandPaletteFilter, HashMap};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    actions, div, Action, AppContext, Component, Div, EventEmitter, FocusHandle, Keystroke,
+    ParentElement, Render, StatelessInteractive, Styled, View, ViewContext, VisualContext,
+    WeakView, WindowContext,
+};
+use picker::{Picker, PickerDelegate};
+use std::cmp::{self, Reverse};
+use theme::ActiveTheme;
+use ui::{modal, Label};
+use util::{
+    channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
+    ResultExt,
+};
+use workspace::{Modal, ModalEvent, Workspace};
+use zed_actions::OpenZedURL;
+
+actions!(Toggle);
+
+pub fn init(cx: &mut AppContext) {
+    cx.set_global(HitCounts::default());
+    cx.observe_new_views(CommandPalette::register).detach();
+}
+
+pub struct CommandPalette {
+    picker: View<Picker<CommandPaletteDelegate>>,
+}
+
+impl CommandPalette {
+    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+        workspace.register_action(|workspace, _: &Toggle, cx| {
+            let Some(previous_focus_handle) = cx.focused() else {
+                return;
+            };
+            workspace.toggle_modal(cx, move |cx| CommandPalette::new(previous_focus_handle, cx));
+        });
+    }
+
+    fn new(previous_focus_handle: FocusHandle, cx: &mut ViewContext<Self>) -> Self {
+        let filter = cx.try_global::<CommandPaletteFilter>();
+
+        let commands = cx
+            .available_actions()
+            .into_iter()
+            .filter_map(|action| {
+                let name = action.name();
+                let namespace = name.split("::").next().unwrap_or("malformed action name");
+                if filter.is_some_and(|f| f.filtered_namespaces.contains(namespace)) {
+                    return None;
+                }
+
+                Some(Command {
+                    name: humanize_action_name(&name),
+                    action,
+                    keystrokes: vec![], // todo!()
+                })
+            })
+            .collect();
+
+        let delegate =
+            CommandPaletteDelegate::new(cx.view().downgrade(), commands, previous_focus_handle);
+
+        let picker = cx.build_view(|cx| Picker::new(delegate, cx));
+        Self { picker }
+    }
+}
+
+impl EventEmitter<ModalEvent> for CommandPalette {}
+impl Modal for CommandPalette {
+    fn focus(&self, cx: &mut WindowContext) {
+        self.picker.update(cx, |picker, cx| picker.focus(cx));
+    }
+}
+
+impl Render for CommandPalette {
+    type Element = Div<Self>;
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+        modal(cx).w_96().child(self.picker.clone())
+    }
+}
+
+pub type CommandPaletteInterceptor =
+    Box<dyn Fn(&str, &AppContext) -> Option<CommandInterceptResult>>;
+
+pub struct CommandInterceptResult {
+    pub action: Box<dyn Action>,
+    pub string: String,
+    pub positions: Vec<usize>,
+}
+
+pub struct CommandPaletteDelegate {
+    command_palette: WeakView<CommandPalette>,
+    commands: Vec<Command>,
+    matches: Vec<StringMatch>,
+    selected_ix: usize,
+    previous_focus_handle: FocusHandle,
+}
+
+struct Command {
+    name: String,
+    action: Box<dyn Action>,
+    keystrokes: Vec<Keystroke>,
+}
+
+impl Clone for Command {
+    fn clone(&self) -> Self {
+        Self {
+            name: self.name.clone(),
+            action: self.action.boxed_clone(),
+            keystrokes: self.keystrokes.clone(),
+        }
+    }
+}
+/// Hit count for each command in the palette.
+/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
+/// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
+#[derive(Default)]
+struct HitCounts(HashMap<String, usize>);
+
+impl CommandPaletteDelegate {
+    fn new(
+        command_palette: WeakView<CommandPalette>,
+        commands: Vec<Command>,
+        previous_focus_handle: FocusHandle,
+    ) -> Self {
+        Self {
+            command_palette,
+            matches: commands
+                .iter()
+                .enumerate()
+                .map(|(i, command)| StringMatch {
+                    candidate_id: i,
+                    string: command.name.clone(),
+                    positions: Vec::new(),
+                    score: 0.0,
+                })
+                .collect(),
+            commands,
+            selected_ix: 0,
+            previous_focus_handle,
+        }
+    }
+}
+
+impl PickerDelegate for CommandPaletteDelegate {
+    type ListItem = Div<Picker<Self>>;
+
+    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<Picker<Self>>) {
+        self.selected_ix = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let mut commands = self.commands.clone();
+
+        cx.spawn(move |picker, mut cx| async move {
+            cx.read_global::<HitCounts, _>(|hit_counts, _| {
+                commands.sort_by_key(|action| {
+                    (
+                        Reverse(hit_counts.0.get(&action.name).cloned()),
+                        action.name.clone(),
+                    )
+                });
+            })
+            .ok();
+
+            let candidates = commands
+                .iter()
+                .enumerate()
+                .map(|(ix, command)| StringMatchCandidate {
+                    id: ix,
+                    string: command.name.to_string(),
+                    char_bag: command.name.chars().collect(),
+                })
+                .collect::<Vec<_>>();
+            let mut 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_executor().clone(),
+                )
+                .await
+            };
+
+            let mut intercept_result = cx
+                .try_read_global(|interceptor: &CommandPaletteInterceptor, cx| {
+                    (interceptor)(&query, cx)
+                })
+                .flatten();
+
+            if *RELEASE_CHANNEL == ReleaseChannel::Dev {
+                if parse_zed_link(&query).is_some() {
+                    intercept_result = Some(CommandInterceptResult {
+                        action: OpenZedURL { url: query.clone() }.boxed_clone(),
+                        string: query.clone(),
+                        positions: vec![],
+                    })
+                }
+            }
+            if let Some(CommandInterceptResult {
+                action,
+                string,
+                positions,
+            }) = intercept_result
+            {
+                if let Some(idx) = matches
+                    .iter()
+                    .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
+                {
+                    matches.remove(idx);
+                }
+                commands.push(Command {
+                    name: string.clone(),
+                    action,
+                    keystrokes: vec![],
+                });
+                matches.insert(
+                    0,
+                    StringMatch {
+                        candidate_id: commands.len() - 1,
+                        string,
+                        positions,
+                        score: 0.0,
+                    },
+                )
+            }
+            picker
+                .update(&mut cx, |picker, _| {
+                    let delegate = &mut picker.delegate;
+                    delegate.commands = commands;
+                    delegate.matches = matches;
+                    if delegate.matches.is_empty() {
+                        delegate.selected_ix = 0;
+                    } else {
+                        delegate.selected_ix =
+                            cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
+                    }
+                })
+                .log_err();
+        })
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        self.command_palette
+            .update(cx, |_, cx| cx.emit(ModalEvent::Dismissed))
+            .log_err();
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if self.matches.is_empty() {
+            self.dismissed(cx);
+            return;
+        }
+        let action_ix = self.matches[self.selected_ix].candidate_id;
+        let command = self.commands.swap_remove(action_ix);
+        cx.update_global(|hit_counts: &mut HitCounts, _| {
+            *hit_counts.0.entry(command.name).or_default() += 1;
+        });
+        let action = command.action;
+        cx.focus(&self.previous_focus_handle);
+        cx.dispatch_action(action);
+        self.dismissed(cx);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> Self::ListItem {
+        let colors = cx.theme().colors();
+        let Some(command) = self
+            .matches
+            .get(ix)
+            .and_then(|m| self.commands.get(m.candidate_id))
+        else {
+            return div();
+        };
+
+        div()
+            .text_color(colors.text)
+            .when(selected, |s| {
+                s.border_l_10().border_color(colors.terminal_ansi_yellow)
+            })
+            .hover(|style| {
+                style
+                    .bg(colors.element_active)
+                    .text_color(colors.text_accent)
+            })
+            .child(Label::new(command.name.clone()))
+    }
+
+    // fn render_match(
+    //     &self,
+    //     ix: usize,
+    //     mouse_state: &mut MouseState,
+    //     selected: bool,
+    //     cx: &gpui::AppContext,
+    // ) -> AnyElement<Picker<Self>> {
+    //     let mat = &self.matches[ix];
+    //     let command = &self.actions[mat.candidate_id];
+    //     let theme = theme::current(cx);
+    //     let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+    //     let key_style = &theme.command_palette.key.in_state(selected);
+    //     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()),
+    //         )
+    //         .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, key_style.label.clone())
+    //                                     .contained()
+    //                                     .with_style(key_style.container),
+    //                             )
+    //                         } else {
+    //                             None
+    //                         }
+    //                     }),
+    //                 )
+    //                 .with_child(
+    //                     Label::new(keystroke.key.clone(), key_style.label.clone())
+    //                         .contained()
+    //                         .with_style(key_style.container),
+    //                 )
+    //                 .contained()
+    //                 .with_margin_left(keystroke_spacing)
+    //                 .flex_float()
+    //         }))
+    //         .contained()
+    //         .with_style(style.container)
+    //         .into_any()
+    // }
+}
+
+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 == '_' {
+            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 std::sync::Arc;
+
+//     use super::*;
+//     use editor::Editor;
+//     use gpui::{executor::Deterministic, TestAppContext};
+//     use project::Project;
+//     use workspace::{AppState, Workspace};
+
+//     #[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"
+//         );
+//         assert_eq!(
+//             humanize_action_name("go_to_line::Deploy"),
+//             "go to line: deploy"
+//         );
+//     }
+
+//     #[gpui::test]
+//     async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
+//         let app_state = init_test(cx);
+
+//         let project = Project::test(app_state.fs.clone(), [], cx).await;
+//         let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+//         let workspace = window.root(cx);
+//         let editor = window.add_view(cx, |cx| {
+//             let mut editor = Editor::single_line(None, cx);
+//             editor.set_text("abc", cx);
+//             editor
+//         });
+
+//         workspace.update(cx, |workspace, cx| {
+//             cx.focus(&editor);
+//             workspace.add_item(Box::new(editor.clone()), cx)
+//         });
+
+//         workspace.update(cx, |workspace, cx| {
+//             toggle_command_palette(workspace, &Toggle, cx);
+//         });
+
+//         let palette = workspace.read_with(cx, |workspace, _| {
+//             workspace.modal::<CommandPalette>().unwrap()
+//         });
+
+//         palette
+//             .update(cx, |palette, cx| {
+//                 // Fill up palette's command list by running an empty query;
+//                 // we only need it to subsequently assert that the palette is initially
+//                 // sorted by command's name.
+//                 palette.delegate_mut().update_matches("".to_string(), cx)
+//             })
+//             .await;
+
+//         palette.update(cx, |palette, _| {
+//             let is_sorted =
+//                 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
+//             assert!(is_sorted(&palette.delegate().actions));
+//         });
+
+//         palette
+//             .update(cx, |palette, cx| {
+//                 palette
+//                     .delegate_mut()
+//                     .update_matches("bcksp".to_string(), cx)
+//             })
+//             .await;
+
+//         palette.update(cx, |palette, cx| {
+//             assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
+//             palette.confirm(&Default::default(), cx);
+//         });
+//         deterministic.run_until_parked();
+//         editor.read_with(cx, |editor, cx| {
+//             assert_eq!(editor.text(cx), "ab");
+//         });
+
+//         // Add namespace filter, and redeploy the palette
+//         cx.update(|cx| {
+//             cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
+//                 filter.filtered_namespaces.insert("editor");
+//             })
+//         });
+
+//         workspace.update(cx, |workspace, cx| {
+//             toggle_command_palette(workspace, &Toggle, cx);
+//         });
+
+//         // Assert editor command not present
+//         let palette = workspace.read_with(cx, |workspace, _| {
+//             workspace.modal::<CommandPalette>().unwrap()
+//         });
+
+//         palette
+//             .update(cx, |palette, cx| {
+//                 palette
+//                     .delegate_mut()
+//                     .update_matches("bcksp".to_string(), cx)
+//             })
+//             .await;
+
+//         palette.update(cx, |palette, _| {
+//             assert!(palette.delegate().matches.is_empty())
+//         });
+//     }
+
+//     fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+//         cx.update(|cx| {
+//             let app_state = AppState::test(cx);
+//             theme::init(cx);
+//             language::init(cx);
+//             editor::init(cx);
+//             workspace::init(app_state.clone(), cx);
+//             init(cx);
+//             Project::init_settings(cx);
+//             app_state
+//         })
+//     }
+// }

crates/go_to_line2/src/go_to_line.rs 🔗

@@ -8,25 +8,12 @@ use text::{Bias, Point};
 use theme::ActiveTheme;
 use ui::{h_stack, modal, v_stack, Label, LabelColor};
 use util::paths::FILE_ROW_COLUMN_DELIMITER;
-use workspace::{ModalEvent, Workspace};
+use workspace::{Modal, ModalEvent, Workspace};
 
 actions!(Toggle);
 
 pub fn init(cx: &mut AppContext) {
-    cx.observe_new_views(
-        |workspace: &mut Workspace, _: &mut ViewContext<Workspace>| {
-            workspace
-                .modal_layer()
-                .register_modal(Toggle, |workspace, cx| {
-                    let editor = workspace
-                        .active_item(cx)
-                        .and_then(|active_item| active_item.downcast::<Editor>())?;
-
-                    Some(cx.build_view(|cx| GoToLine::new(editor, cx)))
-                });
-        },
-    )
-    .detach();
+    cx.observe_new_views(GoToLine::register).detach();
 }
 
 pub struct GoToLine {
@@ -38,14 +25,28 @@ pub struct GoToLine {
 }
 
 impl EventEmitter<ModalEvent> for GoToLine {}
+impl Modal for GoToLine {
+    fn focus(&self, cx: &mut WindowContext) {
+        self.line_editor.update(cx, |editor, cx| editor.focus(cx))
+    }
+}
 
 impl GoToLine {
-    pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
-        let line_editor = cx.build_view(|cx| {
-            let editor = Editor::single_line(cx);
-            editor.focus(cx);
-            editor
+    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+        workspace.register_action(|workspace, _: &Toggle, cx| {
+            let Some(editor) = workspace
+                .active_item(cx)
+                .and_then(|active_item| active_item.downcast::<Editor>())
+            else {
+                return;
+            };
+
+            workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
         });
+    }
+
+    pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
+        let line_editor = cx.build_view(|cx| Editor::single_line(cx));
         let line_editor_change = cx.subscribe(&line_editor, Self::on_line_editor_event);
 
         let editor = active_editor.read(cx);
@@ -123,10 +124,6 @@ impl GoToLine {
     }
 
     fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
-        self.active_editor.update(cx, |editor, cx| {
-            editor.focus(cx);
-            cx.notify();
-        });
         cx.emit(ModalEvent::Dismissed);
     }
 

crates/gpui2/src/action.rs 🔗

@@ -4,7 +4,7 @@ use collections::{HashMap, HashSet};
 use lazy_static::lazy_static;
 use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard};
 use serde::Deserialize;
-use std::any::{type_name, Any};
+use std::any::{type_name, Any, TypeId};
 
 /// Actions are used to implement keyboard-driven UI.
 /// When you declare an action, you can bind keys to the action in the keymap and
@@ -100,6 +100,21 @@ where
     }
 }
 
+impl dyn Action {
+    pub fn type_id(&self) -> TypeId {
+        self.as_any().type_id()
+    }
+
+    pub fn name(&self) -> SharedString {
+        ACTION_REGISTRY
+            .read()
+            .names_by_type_id
+            .get(&self.type_id())
+            .expect("type is not a registered action")
+            .clone()
+    }
+}
+
 type ActionBuilder = fn(json: Option<serde_json::Value>) -> anyhow::Result<Box<dyn Action>>;
 
 lazy_static! {
@@ -109,6 +124,7 @@ lazy_static! {
 #[derive(Default)]
 struct ActionRegistry {
     builders_by_name: HashMap<SharedString, ActionBuilder>,
+    names_by_type_id: HashMap<TypeId, SharedString>,
     all_names: Vec<SharedString>, // So we can return a static slice.
 }
 
@@ -117,9 +133,24 @@ pub fn register_action<A: Action>() {
     let name = A::qualified_name();
     let mut lock = ACTION_REGISTRY.write();
     lock.builders_by_name.insert(name.clone(), A::build);
+    lock.names_by_type_id
+        .insert(TypeId::of::<A>(), name.clone());
     lock.all_names.push(name);
 }
 
+/// Construct an action based on its name and optional JSON parameters sourced from the keymap.
+pub fn build_action_from_type(type_id: &TypeId) -> Result<Box<dyn Action>> {
+    let lock = ACTION_REGISTRY.read();
+    let name = lock
+        .names_by_type_id
+        .get(type_id)
+        .ok_or_else(|| anyhow!("no action type registered for {:?}", type_id))?
+        .clone();
+    drop(lock);
+
+    build_action(&name, None)
+}
+
 /// Construct an action based on its name and optional JSON parameters sourced from the keymap.
 pub fn build_action(name: &str, params: Option<serde_json::Value>) -> Result<Box<dyn Action>> {
     let lock = ACTION_REGISTRY.read();

crates/gpui2/src/elements/uniform_list.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    point, px, AnyElement, AvailableSpace, BorrowWindow, Bounds, Component, Element, ElementId,
-    ElementInteractivity, InteractiveElementState, LayoutId, Pixels, Point, Size,
+    point, px, size, AnyElement, AvailableSpace, BorrowWindow, Bounds, Component, Element,
+    ElementId, ElementInteractivity, InteractiveElementState, LayoutId, Pixels, Point, Size,
     StatefulInteractive, StatefulInteractivity, StatelessInteractive, StatelessInteractivity,
     StyleRefinement, Styled, ViewContext,
 };
@@ -9,6 +9,9 @@ use smallvec::SmallVec;
 use std::{cmp, ops::Range, sync::Arc};
 use taffy::style::Overflow;
 
+/// uniform_list provides lazy rendering for a set of items that are of uniform height.
+/// When rendered into a container with overflow-y: hidden and a fixed (or max) height,
+/// uniform_list will only render the visibile subset of items.
 pub fn uniform_list<Id, V, C>(
     id: Id,
     item_count: usize,
@@ -20,9 +23,12 @@ where
     C: Component<V>,
 {
     let id = id.into();
+    let mut style = StyleRefinement::default();
+    style.overflow.y = Some(Overflow::Hidden);
+
     UniformList {
         id: id.clone(),
-        style: Default::default(),
+        style,
         item_count,
         render_items: Box::new(move |view, visible_range, cx| {
             f(view, visible_range, cx)
@@ -86,8 +92,14 @@ impl<V: 'static> Styled for UniformList<V> {
     }
 }
 
+#[derive(Default)]
+pub struct UniformListState {
+    interactive: InteractiveElementState,
+    item_size: Size<Pixels>,
+}
+
 impl<V: 'static> Element<V> for UniformList<V> {
-    type ElementState = InteractiveElementState;
+    type ElementState = UniformListState;
 
     fn id(&self) -> Option<crate::ElementId> {
         Some(self.id.clone())
@@ -95,20 +107,47 @@ impl<V: 'static> Element<V> for UniformList<V> {
 
     fn initialize(
         &mut self,
-        _: &mut V,
+        view_state: &mut V,
         element_state: Option<Self::ElementState>,
-        _: &mut ViewContext<V>,
+        cx: &mut ViewContext<V>,
     ) -> Self::ElementState {
-        element_state.unwrap_or_default()
+        element_state.unwrap_or_else(|| {
+            let item_size = self.measure_first_item(view_state, None, cx);
+            UniformListState {
+                interactive: InteractiveElementState::default(),
+                item_size,
+            }
+        })
     }
 
     fn layout(
         &mut self,
         _view_state: &mut V,
-        _element_state: &mut Self::ElementState,
+        element_state: &mut Self::ElementState,
         cx: &mut ViewContext<V>,
     ) -> LayoutId {
-        cx.request_layout(&self.computed_style(), None)
+        let max_items = self.item_count;
+        let item_size = element_state.item_size;
+        let rem_size = cx.rem_size();
+
+        cx.request_measured_layout(
+            self.computed_style(),
+            rem_size,
+            move |known_dimensions: Size<Option<Pixels>>, available_space: Size<AvailableSpace>| {
+                let desired_height = item_size.height * max_items;
+                let width = known_dimensions
+                    .width
+                    .unwrap_or(match available_space.width {
+                        AvailableSpace::Definite(x) => x,
+                        AvailableSpace::MinContent | AvailableSpace::MaxContent => item_size.width,
+                    });
+                let height = match available_space.height {
+                    AvailableSpace::Definite(x) => desired_height.min(x),
+                    AvailableSpace::MinContent | AvailableSpace::MaxContent => desired_height,
+                };
+                size(width, height)
+            },
+        )
     }
 
     fn paint(
@@ -133,12 +172,14 @@ impl<V: 'static> Element<V> for UniformList<V> {
         cx.with_z_index(style.z_index.unwrap_or(0), |cx| {
             let content_size;
             if self.item_count > 0 {
-                let item_height = self.measure_item_height(view_state, padded_bounds, cx);
+                let item_height = self
+                    .measure_first_item(view_state, Some(padded_bounds.size.width), cx)
+                    .height;
                 if let Some(scroll_handle) = self.scroll_handle.clone() {
                     scroll_handle.0.lock().replace(ScrollHandleState {
                         item_height,
                         list_height: padded_bounds.size.height,
-                        scroll_offset: element_state.track_scroll_offset(),
+                        scroll_offset: element_state.interactive.track_scroll_offset(),
                     });
                 }
                 let visible_item_count = if item_height > px(0.) {
@@ -147,6 +188,7 @@ impl<V: 'static> Element<V> for UniformList<V> {
                     0
                 };
                 let scroll_offset = element_state
+                    .interactive
                     .scroll_offset()
                     .map_or((0.0).into(), |offset| offset.y);
                 let first_visible_element_ix = (-scroll_offset / item_height).floor() as usize;
@@ -190,20 +232,25 @@ impl<V: 'static> Element<V> for UniformList<V> {
             let overflow = point(style.overflow.x, Overflow::Scroll);
 
             cx.with_z_index(0, |cx| {
-                self.interactivity
-                    .paint(bounds, content_size, overflow, element_state, cx);
+                self.interactivity.paint(
+                    bounds,
+                    content_size,
+                    overflow,
+                    &mut element_state.interactive,
+                    cx,
+                );
             });
         })
     }
 }
 
 impl<V> UniformList<V> {
-    fn measure_item_height(
+    fn measure_first_item(
         &self,
         view_state: &mut V,
-        list_bounds: Bounds<Pixels>,
+        list_width: Option<Pixels>,
         cx: &mut ViewContext<V>,
-    ) -> Pixels {
+    ) -> Size<Pixels> {
         let mut items = (self.render_items)(view_state, 0..1, cx);
         debug_assert!(items.len() == 1);
         let mut item_to_measure = items.pop().unwrap();
@@ -212,11 +259,13 @@ impl<V> UniformList<V> {
         cx.compute_layout(
             layout_id,
             Size {
-                width: AvailableSpace::Definite(list_bounds.size.width),
+                width: list_width.map_or(AvailableSpace::MinContent, |width| {
+                    AvailableSpace::Definite(width)
+                }),
                 height: AvailableSpace::MinContent,
             },
         );
-        cx.layout_bounds(layout_id).size.height
+        cx.layout_bounds(layout_id).size
     }
 
     pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {

crates/gpui2/src/interactive.rs 🔗

@@ -94,7 +94,6 @@ pub trait StatelessInteractive<V: 'static>: Element<V> {
 
     fn on_mouse_down_out(
         mut self,
-        button: MouseButton,
         handler: impl Fn(&mut V, &MouseDownEvent, &mut ViewContext<V>) + 'static,
     ) -> Self
     where
@@ -103,10 +102,7 @@ pub trait StatelessInteractive<V: 'static>: Element<V> {
         self.stateless_interactivity()
             .mouse_down_listeners
             .push(Box::new(move |view, event, bounds, phase, cx| {
-                if phase == DispatchPhase::Capture
-                    && event.button == button
-                    && !bounds.contains_point(&event.position)
-                {
+                if phase == DispatchPhase::Capture && !bounds.contains_point(&event.position) {
                     handler(view, event, cx)
                 }
             }));

crates/gpui2/src/view.rs 🔗

@@ -184,6 +184,10 @@ impl AnyView {
             .compute_layout(layout_id, available_space);
         (self.paint)(self, &mut rendered_element, cx);
     }
+
+    pub(crate) fn draw_dispatch_stack(&self, cx: &mut WindowContext) {
+        (self.initialize)(self, cx);
+    }
 }
 
 impl<V: 'static> Component<V> for AnyView {

crates/gpui2/src/window.rs 🔗

@@ -1,14 +1,15 @@
 use crate::{
-    px, size, Action, AnyBox, AnyDrag, AnyView, AppContext, AsyncWindowContext, AvailableSpace,
-    Bounds, BoxShadow, Context, Corners, CursorStyle, DevicePixels, DispatchContext, DisplayId,
-    Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, FocusEvent, FontId,
-    GlobalElementId, GlyphId, Hsla, ImageData, InputEvent, IsZero, KeyListener, KeyMatch,
-    KeyMatcher, Keystroke, LayoutId, Model, ModelContext, Modifiers, MonochromeSprite, MouseButton,
-    MouseDownEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay,
-    PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptLevel, Quad, Render,
-    RenderGlyphParams, RenderImageParams, RenderSvgParams, ScaledPixels, SceneBuilder, Shadow,
-    SharedString, Size, Style, SubscriberSet, Subscription, TaffyLayoutEngine, Task, Underline,
-    UnderlineStyle, View, VisualContext, WeakView, WindowBounds, WindowOptions, SUBPIXEL_VARIANTS,
+    build_action_from_type, px, size, Action, AnyBox, AnyDrag, AnyView, AppContext,
+    AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, Context, Corners, CursorStyle,
+    DevicePixels, DispatchContext, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter,
+    FileDropEvent, FocusEvent, FontId, GlobalElementId, GlyphId, Hsla, ImageData, InputEvent,
+    IsZero, KeyListener, KeyMatch, KeyMatcher, Keystroke, LayoutId, Model, ModelContext, Modifiers,
+    MonochromeSprite, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels,
+    PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite,
+    PromptLevel, Quad, Render, RenderGlyphParams, RenderImageParams, RenderSvgParams, ScaledPixels,
+    SceneBuilder, Shadow, SharedString, Size, Style, SubscriberSet, Subscription,
+    TaffyLayoutEngine, Task, Underline, UnderlineStyle, View, VisualContext, WeakView,
+    WindowBounds, WindowOptions, SUBPIXEL_VARIANTS,
 };
 use anyhow::{anyhow, Result};
 use collections::HashMap;
@@ -145,6 +146,11 @@ impl FocusHandle {
         }
     }
 
+    /// Moves the focus to the element associated with this handle.
+    pub fn focus(&self, cx: &mut WindowContext) {
+        cx.focus(self)
+    }
+
     /// Obtains whether the element associated with this handle is currently focused.
     pub fn is_focused(&self, cx: &WindowContext) -> bool {
         self.id.is_focused(cx)
@@ -227,7 +233,7 @@ pub(crate) struct Frame {
     key_matchers: HashMap<GlobalElementId, KeyMatcher>,
     mouse_listeners: HashMap<TypeId, Vec<(StackingOrder, AnyListener)>>,
     pub(crate) focus_listeners: Vec<AnyFocusListener>,
-    key_dispatch_stack: Vec<KeyDispatchStackFrame>,
+    pub(crate) key_dispatch_stack: Vec<KeyDispatchStackFrame>,
     freeze_key_dispatch_stack: bool,
     focus_parents_by_child: HashMap<FocusId, FocusId>,
     pub(crate) scene_builder: SceneBuilder,
@@ -326,7 +332,7 @@ impl Window {
 /// find the focused element. We interleave key listeners with dispatch contexts so we can use the
 /// contexts when matching key events against the keymap. A key listener can be either an action
 /// handler or a [KeyDown] / [KeyUp] event listener.
-enum KeyDispatchStackFrame {
+pub(crate) enum KeyDispatchStackFrame {
     Listener {
         event_type: TypeId,
         listener: AnyKeyListener,
@@ -401,11 +407,18 @@ impl<'a> WindowContext<'a> {
 
     /// Move focus to the element associated with the given `FocusHandle`.
     pub fn focus(&mut self, handle: &FocusHandle) {
+        if self.window.focus == Some(handle.id) {
+            return;
+        }
+
         if self.window.last_blur.is_none() {
             self.window.last_blur = Some(self.window.focus);
         }
 
         self.window.focus = Some(handle.id);
+
+        // self.window.current_frame.key_dispatch_stack.clear()
+        // self.window.root_view.initialize()
         self.app.push_effect(Effect::FocusChanged {
             window_handle: self.window.handle,
             focused: Some(handle.id),
@@ -427,6 +440,14 @@ impl<'a> WindowContext<'a> {
         self.notify();
     }
 
+    pub fn dispatch_action(&mut self, action: Box<dyn Action>) {
+        self.defer(|cx| {
+            cx.app.propagate_event = true;
+            let stack = cx.dispatch_stack();
+            cx.dispatch_action_internal(action, &stack[..])
+        })
+    }
+
     /// Schedules the given function to be run at the end of the current effect cycle, allowing entities
     /// that are currently on the stack to be returned to the app.
     pub fn defer(&mut self, f: impl FnOnce(&mut WindowContext) + 'static) {
@@ -1054,6 +1075,26 @@ impl<'a> WindowContext<'a> {
         self.window.dirty = false;
     }
 
+    pub(crate) fn dispatch_stack(&mut self) -> Vec<KeyDispatchStackFrame> {
+        let root_view = self.window.root_view.take().unwrap();
+        let window = &mut *self.window;
+        let mut spare_frame = Frame::default();
+        mem::swap(&mut spare_frame, &mut window.previous_frame);
+
+        self.start_frame();
+
+        root_view.draw_dispatch_stack(self);
+
+        let window = &mut *self.window;
+        // restore the old values of current and previous frame,
+        // putting the new frame into spare_frame.
+        mem::swap(&mut window.current_frame, &mut window.previous_frame);
+        mem::swap(&mut spare_frame, &mut window.previous_frame);
+        self.window.root_view = Some(root_view);
+
+        spare_frame.key_dispatch_stack
+    }
+
     /// Rotate the current frame and the previous frame, then clear the current frame.
     /// We repopulate all state in the current frame during each paint.
     fn start_frame(&mut self) {
@@ -1196,7 +1237,7 @@ impl<'a> WindowContext<'a> {
                                 DispatchPhase::Capture,
                                 self,
                             ) {
-                                self.dispatch_action(action, &key_dispatch_stack[..ix]);
+                                self.dispatch_action_internal(action, &key_dispatch_stack[..ix]);
                             }
                             if !self.app.propagate_event {
                                 break;
@@ -1223,7 +1264,10 @@ impl<'a> WindowContext<'a> {
                                     DispatchPhase::Bubble,
                                     self,
                                 ) {
-                                    self.dispatch_action(action, &key_dispatch_stack[..ix]);
+                                    self.dispatch_action_internal(
+                                        action,
+                                        &key_dispatch_stack[..ix],
+                                    );
                                 }
 
                                 if !self.app.propagate_event {
@@ -1295,7 +1339,29 @@ impl<'a> WindowContext<'a> {
         self.window.platform_window.prompt(level, msg, answers)
     }
 
-    fn dispatch_action(
+    pub fn available_actions(&self) -> impl Iterator<Item = Box<dyn Action>> + '_ {
+        let key_dispatch_stack = &self.window.previous_frame.key_dispatch_stack;
+        key_dispatch_stack.iter().filter_map(|frame| {
+            match frame {
+                // todo!factor out a KeyDispatchStackFrame::Action
+                KeyDispatchStackFrame::Listener {
+                    event_type,
+                    listener: _,
+                } => {
+                    match build_action_from_type(event_type) {
+                        Ok(action) => Some(action),
+                        Err(err) => {
+                            dbg!(err);
+                            None
+                        } // we'll hit his if TypeId == KeyDown
+                    }
+                }
+                KeyDispatchStackFrame::Context(_) => None,
+            }
+        })
+    }
+
+    pub(crate) fn dispatch_action_internal(
         &mut self,
         action: Box<dyn Action>,
         dispatch_stack: &[KeyDispatchStackFrame],

crates/picker2/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 
 [dependencies]
 editor = { package = "editor2", path = "../editor2" }
+ui = { package = "ui2", path = "../ui2" }
 gpui = { package = "gpui2", path = "../gpui2" }
 menu = { package = "menu2", path = "../menu2" }
 settings = { package = "settings2", path = "../settings2" }

crates/picker2/src/picker2.rs 🔗

@@ -5,6 +5,8 @@ use gpui::{
     WindowContext,
 };
 use std::cmp;
+use theme::ActiveTheme;
+use ui::v_stack;
 
 pub struct Picker<D: PickerDelegate> {
     pub delegate: D,
@@ -57,6 +59,7 @@ impl<D: PickerDelegate> Picker<D> {
             let ix = cmp::min(index + 1, count - 1);
             self.delegate.set_selected_index(ix, cx);
             self.scroll_handle.scroll_to_item(ix);
+            cx.notify();
         }
     }
 
@@ -67,6 +70,7 @@ impl<D: PickerDelegate> Picker<D> {
             let ix = index.saturating_sub(1);
             self.delegate.set_selected_index(ix, cx);
             self.scroll_handle.scroll_to_item(ix);
+            cx.notify();
         }
     }
 
@@ -75,6 +79,7 @@ impl<D: PickerDelegate> Picker<D> {
         if count > 0 {
             self.delegate.set_selected_index(0, cx);
             self.scroll_handle.scroll_to_item(0);
+            cx.notify();
         }
     }
 
@@ -83,6 +88,7 @@ impl<D: PickerDelegate> Picker<D> {
         if count > 0 {
             self.delegate.set_selected_index(count - 1, cx);
             self.scroll_handle.scroll_to_item(count - 1);
+            cx.notify();
         }
     }
 
@@ -133,7 +139,7 @@ impl<D: PickerDelegate> Picker<D> {
 impl<D: PickerDelegate> Render for Picker<D> {
     type Element = Div<Self, StatefulInteractivity<Self>, FocusEnabled<Self>>;
 
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
         div()
             .context("picker")
             .id("picker-container")
@@ -146,18 +152,38 @@ impl<D: PickerDelegate> Render for Picker<D> {
             .on_action(Self::cancel)
             .on_action(Self::confirm)
             .on_action(Self::secondary_confirm)
-            .child(self.editor.clone())
             .child(
-                uniform_list("candidates", self.delegate.match_count(), {
-                    move |this: &mut Self, visible_range, cx| {
-                        let selected_ix = this.delegate.selected_index();
-                        visible_range
-                            .map(|ix| this.delegate.render_match(ix, ix == selected_ix, cx))
-                            .collect()
-                    }
-                })
-                .track_scroll(self.scroll_handle.clone())
-                .size_full(),
+                v_stack().gap_px().child(
+                    v_stack()
+                        .py_0p5()
+                        .px_1()
+                        .child(div().px_2().py_0p5().child(self.editor.clone())),
+                ),
+            )
+            .child(
+                div()
+                    .h_px()
+                    .w_full()
+                    .bg(cx.theme().colors().element_background),
+            )
+            .child(
+                v_stack()
+                    .py_0p5()
+                    .px_1()
+                    .grow()
+                    .child(
+                        uniform_list("candidates", self.delegate.match_count(), {
+                            move |this: &mut Self, visible_range, cx| {
+                                let selected_ix = this.delegate.selected_index();
+                                visible_range
+                                    .map(|ix| this.delegate.render_match(ix, ix == selected_ix, cx))
+                                    .collect()
+                            }
+                        })
+                        .track_scroll(self.scroll_handle.clone()),
+                    )
+                    .max_h_72()
+                    .overflow_hidden(),
             )
     }
 }

crates/workspace2/src/modal_layer.rs 🔗

@@ -1,15 +1,22 @@
-use crate::Workspace;
 use gpui::{
-    div, px, AnyView, Component, Div, EventEmitter, ParentElement, Render, StatelessInteractive,
-    Styled, Subscription, View, ViewContext,
+    div, px, AnyView, Div, EventEmitter, FocusHandle, ParentElement, Render, StatelessInteractive,
+    Styled, Subscription, View, ViewContext, VisualContext, WindowContext,
 };
-use std::{any::TypeId, sync::Arc};
 use ui::v_stack;
 
+pub struct ActiveModal {
+    modal: AnyView,
+    subscription: Subscription,
+    previous_focus_handle: Option<FocusHandle>,
+    focus_handle: FocusHandle,
+}
+
 pub struct ModalLayer {
-    open_modal: Option<AnyView>,
-    subscription: Option<Subscription>,
-    registered_modals: Vec<(TypeId, Box<dyn Fn(Div<Workspace>) -> Div<Workspace>>)>,
+    active_modal: Option<ActiveModal>,
+}
+
+pub trait Modal: Render + EventEmitter<ModalEvent> {
+    fn focus(&self, cx: &mut WindowContext);
 }
 
 pub enum ModalEvent {
@@ -18,98 +25,82 @@ pub enum ModalEvent {
 
 impl ModalLayer {
     pub fn new() -> Self {
-        Self {
-            open_modal: None,
-            subscription: None,
-            registered_modals: Vec::new(),
-        }
+        Self { active_modal: None }
     }
 
-    pub fn register_modal<A: 'static, V, B>(&mut self, action: A, build_view: B)
+    pub fn toggle_modal<V, B>(&mut self, cx: &mut ViewContext<Self>, build_view: B)
     where
-        V: EventEmitter<ModalEvent> + Render,
-        B: Fn(&mut Workspace, &mut ViewContext<Workspace>) -> Option<View<V>> + 'static,
+        V: Modal,
+        B: FnOnce(&mut ViewContext<V>) -> V,
     {
-        let build_view = Arc::new(build_view);
-
-        self.registered_modals.push((
-            TypeId::of::<A>(),
-            Box::new(move |mut div| {
-                let build_view = build_view.clone();
-
-                div.on_action(move |workspace, event: &A, cx| {
-                    let Some(new_modal) = (build_view)(workspace, cx) else {
-                        return;
-                    };
-                    workspace.modal_layer().show_modal(new_modal, cx);
-                })
-            }),
-        ));
+        let previous_focus = cx.focused();
+
+        if let Some(active_modal) = &self.active_modal {
+            let is_close = active_modal.modal.clone().downcast::<V>().is_ok();
+            self.hide_modal(cx);
+            if is_close {
+                return;
+            }
+        }
+        let new_modal = cx.build_view(build_view);
+        self.show_modal(new_modal, cx);
     }
 
-    pub fn show_modal<V>(&mut self, new_modal: View<V>, cx: &mut ViewContext<Workspace>)
+    pub fn show_modal<V>(&mut self, new_modal: View<V>, cx: &mut ViewContext<Self>)
     where
-        V: EventEmitter<ModalEvent> + Render,
+        V: Modal,
     {
-        self.subscription = Some(cx.subscribe(&new_modal, |this, modal, e, cx| match e {
-            ModalEvent::Dismissed => this.modal_layer().hide_modal(cx),
-        }));
-        self.open_modal = Some(new_modal.into());
-        cx.notify();
-    }
-
-    pub fn hide_modal(&mut self, cx: &mut ViewContext<Workspace>) {
-        self.open_modal.take();
-        self.subscription.take();
+        self.active_modal = Some(ActiveModal {
+            modal: new_modal.clone().into(),
+            subscription: cx.subscribe(&new_modal, |this, modal, e, cx| match e {
+                ModalEvent::Dismissed => this.hide_modal(cx),
+            }),
+            previous_focus_handle: cx.focused(),
+            focus_handle: cx.focus_handle(),
+        });
+        new_modal.update(cx, |modal, cx| modal.focus(cx));
         cx.notify();
     }
 
-    pub fn wrapper_element(&self, cx: &ViewContext<Workspace>) -> Div<Workspace> {
-        let mut parent = div().relative().size_full();
-
-        for (_, action) in self.registered_modals.iter() {
-            parent = (action)(parent);
+    pub fn hide_modal(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(active_modal) = self.active_modal.take() {
+            if let Some(previous_focus) = active_modal.previous_focus_handle {
+                if active_modal.focus_handle.contains_focused(cx) {
+                    previous_focus.focus(cx);
+                }
+            }
         }
 
-        parent.when_some(self.open_modal.as_ref(), |parent, open_modal| {
-            let container1 = div()
-                .absolute()
-                .flex()
-                .flex_col()
-                .items_center()
-                .size_full()
-                .top_0()
-                .left_0()
-                .z_index(400);
-
-            // transparent layer
-            let container2 = v_stack().h(px(0.0)).relative().top_20();
-
-            parent.child(container1.child(container2.child(open_modal.clone())))
-        })
+        cx.notify();
     }
 }
 
-// impl Render for ModalLayer {
-//     type Element = Div<Self>;
-
-//     fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
-//         let mut div = div();
-//         for (type_id, build_view) in cx.global::<ModalRegistry>().registered_modals {
-//             div = div.useful_on_action(
-//                 type_id,
-//                 Box::new(|this, _: dyn Any, phase, cx: &mut ViewContext<Self>| {
-//                     if phase == DispatchPhase::Capture {
-//                         return;
-//                     }
-//                     self.workspace.update(cx, |workspace, cx| {
-//                         self.open_modal = Some(build_view(workspace, cx));
-//                     });
-//                     cx.notify();
-//                 }),
-//             )
-//         }
-
-//         div
-//     }
-// }
+impl Render for ModalLayer {
+    type Element = Div<Self>;
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+        let Some(active_modal) = &self.active_modal else {
+            return div();
+        };
+
+        div()
+            .absolute()
+            .flex()
+            .flex_col()
+            .items_center()
+            .size_full()
+            .top_0()
+            .left_0()
+            .z_index(400)
+            .child(
+                v_stack()
+                    .h(px(0.0))
+                    .top_20()
+                    .track_focus(&active_modal.focus_handle)
+                    .on_mouse_down_out(|this: &mut Self, event, cx| {
+                        this.hide_modal(cx);
+                    })
+                    .child(active_modal.modal.clone()),
+            )
+    }
+}

crates/workspace2/src/workspace2.rs 🔗

@@ -36,11 +36,12 @@ use futures::{
     Future, FutureExt, StreamExt,
 };
 use gpui::{
-    actions, div, point, rems, size, AnyModel, AnyView, AnyWeakView, AppContext, AsyncAppContext,
-    AsyncWindowContext, Bounds, Component, Div, Entity, EntityId, EventEmitter, FocusHandle,
-    GlobalPixels, Model, ModelContext, ParentElement, Point, Render, Size, StatefulInteractive,
-    Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowBounds,
-    WindowContext, WindowHandle, WindowOptions,
+    actions, div, point, rems, size, Action, AnyModel, AnyView, AnyWeakView, AppContext,
+    AsyncAppContext, AsyncWindowContext, Bounds, Component, DispatchContext, Div, Entity, EntityId,
+    EventEmitter, FocusHandle, GlobalPixels, Model, ModelContext, ParentElement, Point, Render,
+    Size, StatefulInteractive, StatefulInteractivity, StatelessInteractive, Styled, Subscription,
+    Task, View, ViewContext, VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle,
+    WindowOptions,
 };
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
 use itertools::Itertools;
@@ -530,6 +531,13 @@ pub enum Event {
 pub struct Workspace {
     weak_self: WeakView<Self>,
     focus_handle: FocusHandle,
+    workspace_actions: Vec<
+        Box<
+            dyn Fn(
+                Div<Workspace, StatefulInteractivity<Workspace>>,
+            ) -> Div<Workspace, StatefulInteractivity<Workspace>>,
+        >,
+    >,
     zoomed: Option<AnyWeakView>,
     zoomed_position: Option<DockPosition>,
     center: PaneGroup,
@@ -542,7 +550,7 @@ pub struct Workspace {
     last_active_center_pane: Option<WeakView<Pane>>,
     last_active_view_id: Option<proto::ViewId>,
     status_bar: View<StatusBar>,
-    modal_layer: ModalLayer,
+    modal_layer: View<ModalLayer>,
     //     titlebar_item: Option<AnyViewHandle>,
     notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
     project: Model<Project>,
@@ -694,7 +702,7 @@ impl Workspace {
         });
 
         let workspace_handle = cx.view().downgrade();
-        let modal_layer = ModalLayer::new();
+        let modal_layer = cx.build_view(|cx| ModalLayer::new());
 
         // todo!()
         // cx.update_default_global::<DragAndDrop<Workspace>, _, _>(|drag_and_drop, _| {
@@ -775,13 +783,10 @@ impl Workspace {
             leader_updates_tx,
             subscriptions,
             pane_history_timestamp,
+            workspace_actions: Default::default(),
         }
     }
 
-    pub fn modal_layer(&mut self) -> &mut ModalLayer {
-        &mut self.modal_layer
-    }
-
     fn new_local(
         abs_paths: Vec<PathBuf>,
         app_state: Arc<AppState>,
@@ -3495,6 +3500,35 @@ impl Workspace {
     //         )
     //     }
     // }
+    pub fn register_action<A: Action>(
+        &mut self,
+        callback: impl Fn(&mut Self, &A, &mut ViewContext<Self>) + 'static,
+    ) {
+        let callback = Arc::new(callback);
+
+        self.workspace_actions.push(Box::new(move |div| {
+            let callback = callback.clone();
+            div.on_action(move |workspace, event, cx| (callback.clone())(workspace, event, cx))
+        }));
+    }
+
+    fn add_workspace_actions_listeners(
+        &self,
+        mut div: Div<Workspace, StatefulInteractivity<Workspace>>,
+    ) -> Div<Workspace, StatefulInteractivity<Workspace>> {
+        for action in self.workspace_actions.iter() {
+            div = (action)(div)
+        }
+        div
+    }
+
+    pub fn toggle_modal<V: Modal, B>(&mut self, cx: &mut ViewContext<Self>, build: B)
+    where
+        B: FnOnce(&mut ViewContext<V>) -> V,
+    {
+        self.modal_layer
+            .update(cx, |modal_layer, cx| modal_layer.toggle_modal(cx, build))
+    }
 }
 
 fn window_bounds_env_override(cx: &AsyncAppContext) -> Option<WindowBounds> {
@@ -3709,157 +3743,160 @@ impl Render for Workspace {
     type Element = Div<Self>;
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
-        div()
-            .relative()
-            .size_full()
-            .flex()
-            .flex_col()
-            .font("Zed Sans")
-            .gap_0()
-            .justify_start()
-            .items_start()
-            .text_color(cx.theme().colors().text)
-            .bg(cx.theme().colors().background)
-            .child(self.render_titlebar(cx))
-            .child(
-                // todo! should this be a component a view?
-                self.modal_layer
-                    .wrapper_element(cx)
-                    .relative()
-                    .flex_1()
-                    .w_full()
-                    .flex()
-                    .overflow_hidden()
-                    .border_t()
-                    .border_b()
-                    .border_color(cx.theme().colors().border)
-                    // .children(
-                    //     Some(
-                    //         Panel::new("project-panel-outer", cx)
-                    //             .side(PanelSide::Left)
-                    //             .child(ProjectPanel::new("project-panel-inner")),
-                    //     )
-                    //     .filter(|_| self.is_project_panel_open()),
-                    // )
-                    // .children(
-                    //     Some(
-                    //         Panel::new("collab-panel-outer", cx)
-                    //             .child(CollabPanel::new("collab-panel-inner"))
-                    //             .side(PanelSide::Left),
-                    //     )
-                    //     .filter(|_| self.is_collab_panel_open()),
-                    // )
-                    // .child(NotificationToast::new(
-                    //     "maxbrunsfeld has requested to add you as a contact.".into(),
-                    // ))
-                    .child(
-                        div().flex().flex_col().flex_1().h_full().child(
-                            div().flex().flex_1().child(self.center.render(
-                                &self.project,
-                                &self.follower_states,
-                                self.active_call(),
-                                &self.active_pane,
-                                self.zoomed.as_ref(),
-                                &self.app_state,
-                                cx,
-                            )),
+        let mut context = DispatchContext::default();
+        context.insert("Workspace");
+        cx.with_key_dispatch_context(context, |cx| {
+            div()
+                .relative()
+                .size_full()
+                .flex()
+                .flex_col()
+                .font("Zed Sans")
+                .gap_0()
+                .justify_start()
+                .items_start()
+                .text_color(cx.theme().colors().text)
+                .bg(cx.theme().colors().background)
+                .child(self.render_titlebar(cx))
+                .child(
+                    // todo! should this be a component a view?
+                    self.add_workspace_actions_listeners(div().id("workspace"))
+                        .relative()
+                        .flex_1()
+                        .w_full()
+                        .flex()
+                        .overflow_hidden()
+                        .border_t()
+                        .border_b()
+                        .border_color(cx.theme().colors().border)
+                        .child(self.modal_layer.clone())
+                        // .children(
+                        //     Some(
+                        //         Panel::new("project-panel-outer", cx)
+                        //             .side(PanelSide::Left)
+                        //             .child(ProjectPanel::new("project-panel-inner")),
+                        //     )
+                        //     .filter(|_| self.is_project_panel_open()),
+                        // )
+                        // .children(
+                        //     Some(
+                        //         Panel::new("collab-panel-outer", cx)
+                        //             .child(CollabPanel::new("collab-panel-inner"))
+                        //             .side(PanelSide::Left),
+                        //     )
+                        //     .filter(|_| self.is_collab_panel_open()),
+                        // )
+                        // .child(NotificationToast::new(
+                        //     "maxbrunsfeld has requested to add you as a contact.".into(),
+                        // ))
+                        .child(
+                            div().flex().flex_col().flex_1().h_full().child(
+                                div().flex().flex_1().child(self.center.render(
+                                    &self.project,
+                                    &self.follower_states,
+                                    self.active_call(),
+                                    &self.active_pane,
+                                    self.zoomed.as_ref(),
+                                    &self.app_state,
+                                    cx,
+                                )),
+                            ), // .children(
+                               //     Some(
+                               //         Panel::new("terminal-panel", cx)
+                               //             .child(Terminal::new())
+                               //             .allowed_sides(PanelAllowedSides::BottomOnly)
+                               //             .side(PanelSide::Bottom),
+                               //     )
+                               //     .filter(|_| self.is_terminal_open()),
+                               // ),
                         ), // .children(
                            //     Some(
-                           //         Panel::new("terminal-panel", cx)
-                           //             .child(Terminal::new())
-                           //             .allowed_sides(PanelAllowedSides::BottomOnly)
-                           //             .side(PanelSide::Bottom),
+                           //         Panel::new("chat-panel-outer", cx)
+                           //             .side(PanelSide::Right)
+                           //             .child(ChatPanel::new("chat-panel-inner").messages(vec![
+                           //                 ChatMessage::new(
+                           //                     "osiewicz".to_string(),
+                           //                     "is this thing on?".to_string(),
+                           //                     DateTime::parse_from_rfc3339("2023-09-27T15:40:52.707Z")
+                           //                         .unwrap()
+                           //                         .naive_local(),
+                           //                 ),
+                           //                 ChatMessage::new(
+                           //                     "maxdeviant".to_string(),
+                           //                     "Reading you loud and clear!".to_string(),
+                           //                     DateTime::parse_from_rfc3339("2023-09-28T15:40:52.707Z")
+                           //                         .unwrap()
+                           //                         .naive_local(),
+                           //                 ),
+                           //             ])),
+                           //     )
+                           //     .filter(|_| self.is_chat_panel_open()),
+                           // )
+                           // .children(
+                           //     Some(
+                           //         Panel::new("notifications-panel-outer", cx)
+                           //             .side(PanelSide::Right)
+                           //             .child(NotificationsPanel::new("notifications-panel-inner")),
+                           //     )
+                           //     .filter(|_| self.is_notifications_panel_open()),
+                           // )
+                           // .children(
+                           //     Some(
+                           //         Panel::new("assistant-panel-outer", cx)
+                           //             .child(AssistantPanel::new("assistant-panel-inner")),
                            //     )
-                           //     .filter(|_| self.is_terminal_open()),
+                           //     .filter(|_| self.is_assistant_panel_open()),
                            // ),
-                    ), // .children(
-                       //     Some(
-                       //         Panel::new("chat-panel-outer", cx)
-                       //             .side(PanelSide::Right)
-                       //             .child(ChatPanel::new("chat-panel-inner").messages(vec![
-                       //                 ChatMessage::new(
-                       //                     "osiewicz".to_string(),
-                       //                     "is this thing on?".to_string(),
-                       //                     DateTime::parse_from_rfc3339("2023-09-27T15:40:52.707Z")
-                       //                         .unwrap()
-                       //                         .naive_local(),
-                       //                 ),
-                       //                 ChatMessage::new(
-                       //                     "maxdeviant".to_string(),
-                       //                     "Reading you loud and clear!".to_string(),
-                       //                     DateTime::parse_from_rfc3339("2023-09-28T15:40:52.707Z")
-                       //                         .unwrap()
-                       //                         .naive_local(),
-                       //                 ),
-                       //             ])),
-                       //     )
-                       //     .filter(|_| self.is_chat_panel_open()),
-                       // )
-                       // .children(
-                       //     Some(
-                       //         Panel::new("notifications-panel-outer", cx)
-                       //             .side(PanelSide::Right)
-                       //             .child(NotificationsPanel::new("notifications-panel-inner")),
-                       //     )
-                       //     .filter(|_| self.is_notifications_panel_open()),
-                       // )
-                       // .children(
-                       //     Some(
-                       //         Panel::new("assistant-panel-outer", cx)
-                       //             .child(AssistantPanel::new("assistant-panel-inner")),
-                       //     )
-                       //     .filter(|_| self.is_assistant_panel_open()),
-                       // ),
-            )
-            .child(self.status_bar.clone())
-            // .when(self.debug.show_toast, |this| {
-            //     this.child(Toast::new(ToastOrigin::Bottom).child(Label::new("A toast")))
-            // })
-            // .children(
-            //     Some(
-            //         div()
-            //             .absolute()
-            //             .top(px(50.))
-            //             .left(px(640.))
-            //             .z_index(8)
-            //             .child(LanguageSelector::new("language-selector")),
-            //     )
-            //     .filter(|_| self.is_language_selector_open()),
-            // )
-            .z_index(8)
-            // Debug
-            .child(
-                div()
-                    .flex()
-                    .flex_col()
-                    .z_index(9)
-                    .absolute()
-                    .top_20()
-                    .left_1_4()
-                    .w_40()
-                    .gap_2(), // .when(self.show_debug, |this| {
-                              //     this.child(Button::<Workspace>::new("Toggle User Settings").on_click(
-                              //         Arc::new(|workspace, cx| workspace.debug_toggle_user_settings(cx)),
-                              //     ))
-                              //     .child(
-                              //         Button::<Workspace>::new("Toggle Toasts").on_click(Arc::new(
-                              //             |workspace, cx| workspace.debug_toggle_toast(cx),
-                              //         )),
-                              //     )
-                              //     .child(
-                              //         Button::<Workspace>::new("Toggle Livestream").on_click(Arc::new(
-                              //             |workspace, cx| workspace.debug_toggle_livestream(cx),
-                              //         )),
-                              //     )
-                              // })
-                              // .child(
-                              //     Button::<Workspace>::new("Toggle Debug")
-                              //         .on_click(Arc::new(|workspace, cx| workspace.toggle_debug(cx))),
-                              // ),
-            )
+                )
+                .child(self.status_bar.clone())
+                // .when(self.debug.show_toast, |this| {
+                //     this.child(Toast::new(ToastOrigin::Bottom).child(Label::new("A toast")))
+                // })
+                // .children(
+                //     Some(
+                //         div()
+                //             .absolute()
+                //             .top(px(50.))
+                //             .left(px(640.))
+                //             .z_index(8)
+                //             .child(LanguageSelector::new("language-selector")),
+                //     )
+                //     .filter(|_| self.is_language_selector_open()),
+                // )
+                .z_index(8)
+                // Debug
+                .child(
+                    div()
+                        .flex()
+                        .flex_col()
+                        .z_index(9)
+                        .absolute()
+                        .top_20()
+                        .left_1_4()
+                        .w_40()
+                        .gap_2(), // .when(self.show_debug, |this| {
+                                  //     this.child(Button::<Workspace>::new("Toggle User Settings").on_click(
+                                  //         Arc::new(|workspace, cx| workspace.debug_toggle_user_settings(cx)),
+                                  //     ))
+                                  //     .child(
+                                  //         Button::<Workspace>::new("Toggle Toasts").on_click(Arc::new(
+                                  //             |workspace, cx| workspace.debug_toggle_toast(cx),
+                                  //         )),
+                                  //     )
+                                  //     .child(
+                                  //         Button::<Workspace>::new("Toggle Livestream").on_click(Arc::new(
+                                  //             |workspace, cx| workspace.debug_toggle_livestream(cx),
+                                  //         )),
+                                  //     )
+                                  // })
+                                  // .child(
+                                  //     Button::<Workspace>::new("Toggle Debug")
+                                  //         .on_click(Arc::new(|workspace, cx| workspace.toggle_debug(cx))),
+                                  // ),
+                )
+        })
     }
 }
-
 // todo!()
 // impl Entity for Workspace {
 //     type Event = Event;

crates/zed2/Cargo.toml 🔗

@@ -25,7 +25,7 @@ call = { package = "call2", path = "../call2" }
 cli = { path = "../cli" }
 # collab_ui = { path = "../collab_ui" }
 collections = { path = "../collections" }
-# command_palette = { path = "../command_palette" }
+command_palette = { package="command_palette2", path = "../command_palette2" }
 # component_test = { path = "../component_test" }
 # context_menu = { path = "../context_menu" }
 client = { package = "client2", path = "../client2" }
@@ -74,7 +74,7 @@ util = { path = "../util" }
 # vim = { path = "../vim" }
 workspace = { package = "workspace2", path = "../workspace2" }
 # welcome = { path = "../welcome" }
-# zed-actions = {path = "../zed-actions"}
+zed_actions = {package = "zed_actions2", path = "../zed_actions2"}
 anyhow.workspace = true
 async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] }
 async-tar = "0.4.2"

crates/zed2/src/main.rs 🔗

@@ -142,7 +142,7 @@ fn main() {
         // context_menu::init(cx);
         project::Project::init(&client, cx);
         client::init(&client, cx);
-        // command_palette::init(cx);
+        command_palette::init(cx);
         language::init(cx);
         editor::init(cx);
         copilot::init(
@@ -761,7 +761,7 @@ fn load_embedded_fonts(cx: &AppContext) {
 // #[cfg(not(debug_assertions))]
 // async fn watch_languages(_: Arc<dyn Fs>, _: Arc<LanguageRegistry>) -> Option<()> {
 //     None
-// }
+//
 
 // #[cfg(not(debug_assertions))]
 // fn watch_file_types(_fs: Arc<dyn Fs>, _cx: &mut AppContext) {}

crates/zed_actions2/Cargo.toml 🔗

@@ -0,0 +1,11 @@
+[package]
+name = "zed_actions2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+gpui = { package = "gpui2", path = "../gpui2" }
+serde.workspace = true

crates/zed_actions2/src/lib.rs 🔗

@@ -0,0 +1,34 @@
+use gpui::{action, actions};
+
+actions!(
+    About,
+    DebugElements,
+    DecreaseBufferFontSize,
+    Hide,
+    HideOthers,
+    IncreaseBufferFontSize,
+    Minimize,
+    OpenDefaultKeymap,
+    OpenDefaultSettings,
+    OpenKeymap,
+    OpenLicenses,
+    OpenLocalSettings,
+    OpenLog,
+    OpenSettings,
+    OpenTelemetryLog,
+    Quit,
+    ResetBufferFontSize,
+    ResetDatabase,
+    ShowAll,
+    ToggleFullScreen,
+    Zoom,
+);
+
+#[action]
+pub struct OpenBrowser {
+    pub url: String,
+}
+#[action]
+pub struct OpenZedURL {
+    pub url: String,
+}