diff --git a/Cargo.lock b/Cargo.lock index 4cba8251cf..484dab47d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,22 @@ 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", + "serde_json", + "settings", + "theme", + "util", + "workspace", +] + [[package]] name = "comrak" version = "0.10.1" @@ -6138,6 +6154,7 @@ dependencies = [ "client", "clock", "collections", + "command_palette", "contacts_panel", "crossbeam-channel", "ctor", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index d9d5742b66..e517f853d5 100644 --- a/assets/keymaps/default.json +++ b/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": [ diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml new file mode 100644 index 0000000000..b360a04125 --- /dev/null +++ b/crates/command_palette/Cargo.toml @@ -0,0 +1,24 @@ +[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" } +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" diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs new file mode 100644 index 0000000000..7e1c2b43b1 --- /dev/null +++ b/crates/command_palette/src/command_palette.rs @@ -0,0 +1,179 @@ +use std::cmp; + +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + actions, + elements::{ChildView, Label}, + Action, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle, +}; +use selector::{SelectorModal, SelectorModalDelegate}; +use settings::Settings; +use workspace::Workspace; + +mod selector; + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(CommandPalette::toggle); + selector::init::(cx); +} + +actions!(command_palette, [Toggle]); + +pub struct CommandPalette { + selector: ViewHandle>, + actions: Vec<(&'static str, Box)>, + matches: Vec, + selected_ix: usize, + focused_view_id: usize, +} + +pub enum Event { + Dismissed, +} + +impl CommandPalette { + pub fn new( + focused_view_id: usize, + actions: Vec<(&'static str, Box)>, + cx: &mut ViewContext, + ) -> Self { + let this = cx.weak_handle(); + let selector = cx.add_view(|cx| SelectorModal::new(this, cx)); + Self { + selector, + actions, + matches: vec![], + selected_ix: 0, + focused_view_id, + } + } + + fn toggle(_: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { + 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 actions = cx.available_actions(window_id, focused_view_id); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx, _| { + let selector = cx.add_view(|cx| Self::new(focused_view_id, actions, cx)); + cx.subscribe(&selector, Self::on_event).detach(); + selector + }); + }); + }); + } + + fn on_event( + workspace: &mut Workspace, + _: ViewHandle, + event: &Event, + cx: &mut ViewContext, + ) { + match event { + Event::Dismissed => { + workspace.dismiss_modal(cx); + } + } + } +} + +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.selector.clone()).boxed() + } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.selector); + } +} + +impl SelectorModalDelegate 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) { + self.selected_ix = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut gpui::ViewContext, + ) -> gpui::Task<()> { + let candidates = self + .actions + .iter() + .enumerate() + .map(|(ix, (name, _))| StringMatchCandidate { + id: ix, + string: name.to_string(), + char_bag: name.chars().collect(), + }) + .collect::>(); + cx.spawn(move |this, mut cx| async move { + let matches = 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) { + cx.emit(Event::Dismissed); + } + + fn confirm(&mut self, cx: &mut ViewContext) { + if !self.matches.is_empty() { + let window_id = cx.window_id(); + let action_ix = self.matches[self.selected_ix].candidate_id; + cx.dispatch_action_at( + window_id, + self.focused_view_id, + self.actions[action_ix].1.as_ref(), + ) + } + cx.emit(Event::Dismissed); + } + + fn render_match(&self, ix: usize, selected: bool, cx: &gpui::AppContext) -> gpui::ElementBox { + let settings = cx.global::(); + let theme = &settings.theme.selector; + let style = if selected { + &theme.active_item + } else { + &theme.item + }; + Label::new(self.matches[ix].string.clone(), style.label.clone()) + .contained() + .with_style(style.container) + .boxed() + } +} diff --git a/crates/command_palette/src/selector.rs b/crates/command_palette/src/selector.rs new file mode 100644 index 0000000000..daa0f5ff6a --- /dev/null +++ b/crates/command_palette/src/selector.rs @@ -0,0 +1,222 @@ +use editor::Editor; +use gpui::{ + elements::{ + ChildView, Flex, FlexItem, Label, ParentElement, ScrollTarget, UniformList, + UniformListState, + }, + 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, SelectLast, SelectNext, SelectPrev}; + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(SelectorModal::::select_first); + cx.add_action(SelectorModal::::select_last); + cx.add_action(SelectorModal::::select_next); + cx.add_action(SelectorModal::::select_prev); + cx.add_action(SelectorModal::::confirm); + cx.add_action(SelectorModal::::cancel); +} + +pub struct SelectorModal { + delegate: WeakViewHandle, + query_editor: ViewHandle, + list_state: UniformListState, +} + +pub trait SelectorModalDelegate: View { + fn match_count(&self) -> usize; + fn selected_index(&self) -> usize; + fn set_selected_index(&mut self, ix: usize); + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> Task<()>; + fn confirm(&mut self, cx: &mut ViewContext); + fn dismiss(&mut self, cx: &mut ViewContext); + fn render_match(&self, ix: usize, selected: bool, cx: &AppContext) -> ElementBox; +} + +impl Entity for SelectorModal { + type Event = (); +} + +impl View for SelectorModal { + fn ui_name() -> &'static str { + "SelectorModal" + } + + fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { + let settings = cx.global::(); + 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(), + ) + .contained() + .with_style(settings.theme.selector.container) + .constrained() + .with_max_width(500.0) + .with_max_height(420.0) + .aligned() + .top() + .named("selector") + } + + 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) { + cx.focus(&self.query_editor); + } +} + +impl SelectorModal { + pub fn new(delegate: WeakViewHandle, cx: &mut ViewContext) -> 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(); + + Self { + delegate, + query_editor, + list_state: Default::default(), + } + } + + fn render_matches(&self, cx: &AppContext) -> ElementBox { + let delegate = self.delegate.clone(); + let match_count = if let Some(delegate) = delegate.upgrade(cx) { + delegate.read(cx).match_count() + } else { + 0 + }; + + if match_count == 0 { + let settings = cx.global::(); + return Label::new( + "No matches".into(), + settings.theme.selector.empty.label.clone(), + ) + .contained() + .with_style(settings.theme.selector.empty.container) + .named("empty matches"); + } + + 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| delegate.render_match(ix, ix == selected_ix, cx))); + }, + ) + .contained() + .with_margin_top(6.0) + .named("matches") + } + + fn on_query_editor_event( + &mut self, + _: ViewHandle, + event: &editor::Event, + cx: &mut ViewContext, + ) { + if let Some(delegate) = self.delegate.upgrade(cx) { + match event { + editor::Event::BufferEdited { .. } => { + let query = self.query_editor.read(cx).text(cx); + let update = delegate.update(cx, |d, cx| d.update_matches(query, cx)); + cx.spawn(|this, mut cx| async move { + update.await; + this.update(&mut cx, |_, cx| cx.notify()); + }) + .detach(); + } + editor::Event::Blurred => delegate.update(cx, |delegate, cx| { + delegate.dismiss(cx); + }), + _ => {} + } + } + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = 0; + delegate.update(cx, |delegate, _| delegate.set_selected_index(0)); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, _| { + let match_count = delegate.match_count(); + let index = if match_count > 0 { match_count - 1 } else { 0 }; + delegate.set_selected_index(index); + index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, _| { + let mut selected_index = delegate.selected_index(); + if selected_index + 1 < delegate.match_count() { + selected_index += 1; + delegate.set_selected_index(selected_index); + } + selected_index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + let index = delegate.update(cx, |delegate, _| { + let mut selected_index = delegate.selected_index(); + if selected_index > 0 { + selected_index -= 1; + delegate.set_selected_index(selected_index); + } + selected_index + }); + self.list_state.scroll_to(ScrollTarget::Show(index)); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + delegate.update(cx, |delegate, cx| delegate.confirm(cx)); + } + } + + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if let Some(delegate) = self.delegate.upgrade(cx) { + delegate.update(cx, |delegate, cx| delegate.dismiss(cx)); + } + } +} diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f3f2c31d2c..8236878894 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -275,9 +275,7 @@ impl FileFinder { fn project_updated(&mut self, _: ModelHandle, cx: &mut ViewContext) { 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(query, cx).detach(); } fn on_query_editor_event( @@ -294,9 +292,7 @@ impl FileFinder { self.matches.clear(); cx.notify(); } else { - if let Some(task) = self.spawn_search(query, cx) { - task.detach(); - } + self.spawn_search(query, cx).detach(); } } editor::Event::Blurred => cx.emit(Event::Dismissed), @@ -354,14 +350,13 @@ impl FileFinder { cx.emit(Event::Selected(project_path.clone())); } - #[must_use] - fn spawn_search(&mut self, query: String, cx: &mut ViewContext) -> Option> { + fn spawn_search(&mut self, query: String, cx: &mut ViewContext) -> 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 { + 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) @@ -371,7 +366,7 @@ impl FileFinder { this.update(&mut cx, |this, cx| { this.update_matches((search_id, did_cancel, query, matches), cx) }); - })) + }) } fn update_matches( @@ -514,7 +509,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,7 +517,7 @@ 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.spawn_search(query.clone(), cx).detach(); finder.update_matches( ( finder.latest_search_id, @@ -535,7 +529,7 @@ mod tests { ); // Simulate another cancellation. - finder.spawn_search(query.clone(), cx).unwrap().detach(); + finder.spawn_search(query.clone(), cx).detach(); finder.update_matches( ( finder.latest_search_id, @@ -576,7 +570,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 +587,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,7 +625,6 @@ 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. diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 9724a6c094..f872177082 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1340,6 +1340,17 @@ impl MutableAppContext { .collect() } + 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( &mut self, window_id: usize, diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index 4585e321c4..793f41f487 100644 --- a/crates/gpui/src/presenter.rs +++ b/crates/gpui/src/presenter.rs @@ -51,15 +51,21 @@ impl Presenter { } pub fn dispatch_path(&self, app: &AppContext) -> Vec { - 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(); + 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 { + let mut path = Vec::new(); + 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 } diff --git a/crates/workspace/src/menu.rs b/crates/workspace/src/menu.rs index 33de4a677a..81716028b9 100644 --- a/crates/workspace/src/menu.rs +++ b/crates/workspace/src/menu.rs @@ -1,4 +1,11 @@ gpui::actions!( menu, - [Confirm, SelectPrev, SelectNext, SelectFirst, SelectLast] + [ + Cancel, + Confirm, + SelectPrev, + SelectNext, + SelectFirst, + SelectLast + ] ); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 05efff0ebd..24a06b9f67 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 41e27eb494..cead3ac390 100644 --- a/crates/zed/src/main.rs +++ b/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);