diff --git a/Cargo.lock b/Cargo.lock index 98c39f56b2..43efeab533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2183,6 +2183,7 @@ dependencies = [ "language", "menu", "picker", + "postage", "project", "release_channel", "serde", diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index cf11502741..f95525542e 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -28,6 +28,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +postage.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 2a7a94b544..e7edf393ff 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -1,6 +1,7 @@ use std::{ cmp::{self, Reverse}, sync::Arc, + time::Duration, }; use client::telemetry::Telemetry; @@ -9,11 +10,12 @@ use copilot::CommandPaletteFilter; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global, - ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, + ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; -use release_channel::{parse_zed_link, ReleaseChannel}; +use postage::{sink::Sink, stream::Stream}; +use release_channel::parse_zed_link; use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{ModalView, Workspace}; @@ -119,6 +121,10 @@ pub struct CommandPaletteDelegate { selected_ix: usize, telemetry: Arc, previous_focus_handle: FocusHandle, + updating_matches: Option<( + Task<()>, + postage::dispatch::Receiver<(Vec, Vec)>, + )>, } struct Command { @@ -138,7 +144,7 @@ impl Clone for Command { /// 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 a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it. -#[derive(Default)] +#[derive(Default, Clone)] struct HitCounts(HashMap); impl Global for HitCounts {} @@ -158,6 +164,66 @@ impl CommandPaletteDelegate { selected_ix: 0, telemetry, previous_focus_handle, + updating_matches: None, + } + } + + fn matches_updated( + &mut self, + query: String, + mut commands: Vec, + mut matches: Vec, + cx: &mut ViewContext>, + ) { + self.updating_matches.take(); + + let mut intercept_result = + if let Some(interceptor) = cx.try_global::() { + (interceptor.0)(&query, cx) + } else { + None + }; + + 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, + }); + matches.insert( + 0, + StringMatch { + candidate_id: commands.len() - 1, + string, + positions, + score: 0.0, + }, + ) + } + self.commands = commands; + self.matches = matches; + if self.matches.is_empty() { + self.selected_ix = 0; + } else { + self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1); } } } @@ -186,113 +252,99 @@ impl PickerDelegate for CommandPaletteDelegate { query: String, cx: &mut ViewContext>, ) -> gpui::Task<()> { - let mut commands = self.all_commands.clone(); - - cx.spawn(move |picker, mut cx| async move { - cx.read_global::(|hit_counts, _| { + let (mut tx, mut rx) = postage::dispatch::channel(1); + let task = cx.background_executor().spawn({ + let mut commands = self.all_commands.clone(); + let hit_counts = cx.global::().clone(); + let executor = cx.background_executor().clone(); + let query = query.clone(); + async move { 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::>(); - let mut matches = if query.is_empty() { - candidates - .into_iter() + let candidates = commands + .iter() .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, + .map(|(ix, command)| StringMatchCandidate { + id: ix, + string: command.name.to_string(), + char_bag: command.name.chars().collect(), }) - .collect() - } else { - fuzzy::match_strings( - &candidates, - &query, - true, - 10000, - &Default::default(), - cx.background_executor().clone(), - ) - .await + .collect::>(); + + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + let ret = fuzzy::match_strings( + &candidates, + &query, + true, + 10000, + &Default::default(), + executor, + ) + .await; + ret + }; + + tx.send((commands, matches)).await.log_err(); + } + }); + self.updating_matches = Some((task, rx.clone())); + + cx.spawn(move |picker, mut cx| async move { + let Some((commands, matches)) = rx.recv().await else { + return; }; - let mut intercept_result = cx - .try_read_global(|interceptor: &CommandPaletteInterceptor, cx| { - (interceptor.0)(&query, cx) - }) - .flatten(); - let release_channel = cx - .update(|cx| ReleaseChannel::try_global(cx)) - .ok() - .flatten(); - if release_channel == Some(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, - }); - 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); - } + .update(&mut cx, |picker, cx| { + picker + .delegate + .matches_updated(query, commands, matches, cx) }) .log_err(); }) } + fn finalize_update_matches( + &mut self, + query: String, + duration: Duration, + cx: &mut ViewContext>, + ) -> bool { + let Some((task, rx)) = self.updating_matches.take() else { + return true; + }; + + match cx + .background_executor() + .block_with_timeout(duration, rx.clone().recv()) + { + Ok(Some((commands, matches))) => { + self.matches_updated(query, commands, matches, cx); + true + } + _ => { + self.updating_matches = Some((task, rx)); + false + } + } + } + fn dismissed(&mut self, cx: &mut ViewContext>) { self.command_palette .update(cx, |_, cx| cx.emit(DismissEvent)) diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 9ad0839088..11ee49ac95 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -147,7 +147,7 @@ impl EditorTestContext { self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text)); let keystroke = Keystroke::parse(keystroke_text).unwrap(); - self.cx.dispatch_keystroke(self.window, keystroke, false); + self.cx.dispatch_keystroke(self.window, keystroke); keystroke_under_test_handle } diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index c6ea705b57..44e6ec17ff 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -39,7 +39,7 @@ use std::any::{Any, TypeId}; /// } /// register_action!(Paste); /// ``` -pub trait Action: 'static { +pub trait Action: 'static + Send { /// Clone the action into a new box fn boxed_clone(&self) -> Box; diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0f64a0690f..a3e1eda056 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -335,7 +335,7 @@ impl TestAppContext { .map(Keystroke::parse) .map(Result::unwrap) { - self.dispatch_keystroke(window, keystroke.into(), false); + self.dispatch_keystroke(window, keystroke.into()); } self.background_executor.run_until_parked() @@ -347,21 +347,16 @@ impl TestAppContext { /// This will also run the background executor until it's parked. pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) { - self.dispatch_keystroke(window, keystroke.into(), false); + self.dispatch_keystroke(window, keystroke.into()); } self.background_executor.run_until_parked() } /// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`) - pub fn dispatch_keystroke( - &mut self, - window: AnyWindowHandle, - keystroke: Keystroke, - is_held: bool, - ) { - self.test_window(window) - .simulate_keystroke(keystroke, is_held) + pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) { + self.update_window(window, |_, cx| cx.dispatch_keystroke(keystroke)) + .unwrap(); } /// Returns the `TestWindow` backing the given handle. diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 1bc32717c4..69798abe28 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -491,8 +491,8 @@ mod test { .update(cx, |test_view, cx| cx.focus(&test_view.focus_handle)) .unwrap(); - cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap(), false); - cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap(), false); + cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap()); + cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap()); window .update(cx, |test_view, _| { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index de39ef61cc..7e57009af2 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -412,7 +412,7 @@ impl PlatformInputHandler { .flatten() } - pub(crate) fn flush_pending_input(&mut self, input: &str, cx: &mut WindowContext) { + pub(crate) fn dispatch_input(&mut self, input: &str, cx: &mut WindowContext) { self.handler.replace_text_in_range(None, input, cx); } } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 940a3e44c5..49ce7bd771 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,7 +1,7 @@ use crate::{ - px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, + px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, Pixels, PlatformAtlas, + PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, Size, + TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, }; use collections::HashMap; use parking_lot::Mutex; @@ -112,41 +112,6 @@ impl TestWindow { self.0.lock().input_callback = Some(callback); result } - - pub fn simulate_keystroke(&mut self, mut keystroke: Keystroke, is_held: bool) { - if keystroke.ime_key.is_none() - && !keystroke.modifiers.command - && !keystroke.modifiers.control - && !keystroke.modifiers.function - { - keystroke.ime_key = Some(if keystroke.modifiers.shift { - keystroke.key.to_ascii_uppercase().clone() - } else { - keystroke.key.clone() - }) - } - - if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held, - })) { - return; - } - - let mut lock = self.0.lock(); - let Some(mut input_handler) = lock.input_handler.take() else { - panic!( - "simulate_keystroke {:?} input event was not handled and there was no active input", - &keystroke - ); - }; - drop(lock); - if let Some(text) = keystroke.ime_key.as_ref() { - input_handler.replace_text_in_range(None, &text); - } - - self.0.lock().input_handler = Some(input_handler); - } } impl PlatformWindow for TestWindow { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 351ab15bba..47cab51910 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -950,7 +950,7 @@ impl<'a> WindowContext<'a> { /// Produces a new frame and assigns it to `rendered_frame`. To actually show /// the contents of the new [Scene], use [present]. - pub(crate) fn draw(&mut self) { + pub fn draw(&mut self) { self.window.dirty.set(false); self.window.drawing = true; @@ -1099,6 +1099,38 @@ impl<'a> WindowContext<'a> { self.window.needs_present.set(false); } + /// Dispatch a given keystroke as though the user had typed it. + /// You can create a keystroke with Keystroke::parse(""). + pub fn dispatch_keystroke(&mut self, mut keystroke: Keystroke) -> bool { + if keystroke.ime_key.is_none() + && !keystroke.modifiers.command + && !keystroke.modifiers.control + && !keystroke.modifiers.function + { + keystroke.ime_key = Some(if keystroke.modifiers.shift { + keystroke.key.to_uppercase().clone() + } else { + keystroke.key.clone() + }) + } + if self.dispatch_event(PlatformInput::KeyDown(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + })) { + return true; + } + + if let Some(input) = keystroke.ime_key { + if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { + input_handler.dispatch_input(&input, self); + self.window.platform_window.set_input_handler(input_handler); + return true; + } + } + + false + } + /// Dispatch a mouse or keyboard event on the window. pub fn dispatch_event(&mut self, event: PlatformInput) -> bool { self.window.last_input_timestamp.set(Instant::now()); @@ -1423,7 +1455,7 @@ impl<'a> WindowContext<'a> { if !input.is_empty() { if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { - input_handler.flush_pending_input(&input, self); + input_handler.dispatch_input(&input, self); self.window.platform_window.set_input_handler(input_handler) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 09019a8434..32add0b509 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -4,7 +4,7 @@ use gpui::{ FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, }; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing}; use workspace::ModalView; @@ -40,6 +40,19 @@ pub trait PickerDelegate: Sized + 'static { fn placeholder_text(&self) -> Arc; fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()>; + // Delegates that support this method (e.g. the CommandPalette) can chose to block on any background + // work for up to `duration` to try and get a result synchronously. + // This avoids a flash of an empty command-palette on cmd-shift-p, and lets workspace::SendKeystrokes + // mostly work when dismissing a palette. + fn finalize_update_matches( + &mut self, + _query: String, + _duration: Duration, + _cx: &mut ViewContext>, + ) -> bool { + false + } + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); fn dismissed(&mut self, cx: &mut ViewContext>); @@ -98,6 +111,9 @@ impl Picker { is_modal: true, }; this.update_matches("".to_string(), cx); + // give the delegate 4ms to renderthe first set of suggestions. + this.delegate + .finalize_update_matches("".to_string(), Duration::from_millis(4), cx); this } @@ -197,15 +213,24 @@ impl Picker { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - if self.pending_update_matches.is_some() { + if self.pending_update_matches.is_some() + && !self + .delegate + .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx) + { self.confirm_on_update = Some(false) } else { + self.pending_update_matches.take(); self.delegate.confirm(false, cx); } } fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { - if self.pending_update_matches.is_some() { + if self.pending_update_matches.is_some() + && !self + .delegate + .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx) + { self.confirm_on_update = Some(true) } else { self.delegate.confirm(true, cx); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 8051a4761a..75961f8750 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -884,3 +884,70 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { rename_request.next().await.unwrap(); cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal) } + +#[gpui::test] +async fn test_remap(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test moving the cursor + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g z", + workspace::SendKeystrokes("l l l l".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "z"]); + cx.assert_state("1234ˇ56789", Mode::Normal); + + // test switching modes + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g y", + workspace::SendKeystrokes("i f o o escape l".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "y"]); + cx.assert_state("fooˇ123456789", Mode::Normal); + + // test recursion + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g x", + workspace::SendKeystrokes("g z g y".to_string()), + None, + )]) + }); + cx.set_state("ˇ123456789", Mode::Normal); + cx.simulate_keystrokes(["g", "x"]); + cx.assert_state("1234fooˇ56789", Mode::Normal); + + cx.executor().allow_parking(); + + // test command + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g w", + workspace::SendKeystrokes(": j enter".to_string()), + None, + )]) + }); + cx.set_state("ˇ1234\n56789", Mode::Normal); + cx.simulate_keystrokes(["g", "w"]); + cx.assert_state("1234ˇ 56789", Mode::Normal); + + // test leaving command + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "g u", + workspace::SendKeystrokes("g w g z".to_string()), + None, + )]) + }); + cx.set_state("ˇ1234\n56789", Mode::Normal); + cx.simulate_keystrokes(["g", "u"]); + cx.assert_state("1234 567ˇ89", Mode::Normal); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 11dd49d373..883a051d83 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -29,10 +29,10 @@ use gpui::{ actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, - FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, - ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, - Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, - WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions, + FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke, + LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, + PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, + VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions, }; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem}; use itertools::Itertools; @@ -59,10 +59,11 @@ pub use status_bar::StatusItemView; use std::{ any::TypeId, borrow::Cow, + cell::RefCell, cmp, env, path::{Path, PathBuf}, - sync::Weak, - sync::{atomic::AtomicUsize, Arc}, + rc::Rc, + sync::{atomic::AtomicUsize, Arc, Weak}, time::Duration, }; use theme::{ActiveTheme, SystemAppearance, ThemeSettings}; @@ -157,6 +158,9 @@ pub struct CloseAllItemsAndPanes { pub save_intent: Option, } +#[derive(Clone, Deserialize, PartialEq)] +pub struct SendKeystrokes(pub String); + impl_actions!( workspace, [ @@ -168,6 +172,7 @@ impl_actions!( Save, SaveAll, SwapPaneInDirection, + SendKeystrokes, ] ); @@ -499,6 +504,7 @@ pub struct Workspace { leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, app_state: Arc, + dispatching_keystrokes: Rc>>, _subscriptions: Vec, _apply_leader_updates: Task>, _observe_current_user: Task>, @@ -754,6 +760,7 @@ impl Workspace { project: project.clone(), follower_states: Default::default(), last_leaders_by_pane: Default::default(), + dispatching_keystrokes: Default::default(), window_edited: false, active_call, database_id: workspace_id, @@ -1252,6 +1259,46 @@ impl Workspace { .detach_and_log_err(cx); } + fn send_keystrokes(&mut self, action: &SendKeystrokes, cx: &mut ViewContext) { + let mut keystrokes: Vec = action + .0 + .split(" ") + .flat_map(|k| Keystroke::parse(k).log_err()) + .collect(); + keystrokes.reverse(); + + self.dispatching_keystrokes + .borrow_mut() + .append(&mut keystrokes); + + let keystrokes = self.dispatching_keystrokes.clone(); + cx.window_context() + .spawn(|mut cx| async move { + // limit to 100 keystrokes to avoid infinite recursion. + for _ in 0..100 { + let Some(keystroke) = keystrokes.borrow_mut().pop() else { + return Ok(()); + }; + cx.update(|cx| { + let focused = cx.focused(); + cx.dispatch_keystroke(keystroke.clone()); + if cx.focused() != focused { + // dispatch_keystroke may cause the focus to change. + // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle + // And we need that to happen before the next keystroke to keep vim mode happy... + // (Note that the tests always do this implicitly, so you must manually test with something like: + // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} + // ) + cx.draw(); + } + })?; + } + keystrokes.borrow_mut().clear(); + Err(anyhow!("over 100 keystrokes passed to send_keystrokes")) + }) + .detach_and_log_err(cx); + } + fn save_all_internal( &mut self, mut save_intent: SaveIntent, @@ -3461,6 +3508,7 @@ impl Workspace { .on_action(cx.listener(Self::close_inactive_items_and_panes)) .on_action(cx.listener(Self::close_all_items_and_panes)) .on_action(cx.listener(Self::save_all)) + .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(Self::follow_next_collaborator)) .on_action(cx.listener(|workspace, _: &Unfollow, cx| {