Cargo.lock π
@@ -2183,6 +2183,7 @@ dependencies = [
"language",
"menu",
"picker",
+ "postage",
"project",
"release_channel",
"serde",
N and Conrad Irwin created
Release Notes:
- Added `workspace::SendKeystrokes` to enable mapping from one key to a
sequence of others
([#7033](https://github.com/zed-industries/zed/issues/7033)).
Improves #7033. Big thank you to @ConradIrwin who did most of the heavy
lifting on this one.
This PR allows the user to send multiple keystrokes via custom
keybinding. For example, the following keybinding would go down four
lines and then right four characters.
```json
[
{
"context": "Editor && VimControl && !VimWaiting && !menu",
"bindings": {
"g z": [
"workspace::SendKeystrokes",
"j j j j l l l l"
],
}
}
]
```
---------
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Cargo.lock | 1
crates/command_palette/Cargo.toml | 1
crates/command_palette/src/command_palette.rs | 234 ++++++++++++--------
crates/editor/src/test/editor_test_context.rs | 2
crates/gpui/src/action.rs | 2
crates/gpui/src/app/test_context.rs | 15
crates/gpui/src/interactive.rs | 4
crates/gpui/src/platform.rs | 2
crates/gpui/src/platform/test/window.rs | 41 ---
crates/gpui/src/window.rs | 36 +++
crates/picker/src/picker.rs | 31 ++
crates/vim/src/test.rs | 67 ++++++
crates/workspace/src/workspace.rs | 60 ++++
13 files changed, 341 insertions(+), 155 deletions(-)
@@ -2183,6 +2183,7 @@ dependencies = [
"language",
"menu",
"picker",
+ "postage",
"project",
"release_channel",
"serde",
@@ -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
@@ -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<Telemetry>,
previous_focus_handle: FocusHandle,
+ updating_matches: Option<(
+ Task<()>,
+ postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
+ )>,
}
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<String, usize>);
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<Command>,
+ mut matches: Vec<StringMatch>,
+ cx: &mut ViewContext<Picker<Self>>,
+ ) {
+ self.updating_matches.take();
+
+ let mut intercept_result =
+ if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
+ (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<Picker<Self>>,
) -> gpui::Task<()> {
- let mut commands = self.all_commands.clone();
-
- cx.spawn(move |picker, mut cx| async move {
- cx.read_global::<HitCounts, _>(|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::<HitCounts>().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::<Vec<_>>();
- 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::<Vec<_>>();
+
+ 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
+ };
- 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![],
- })
- }
+ tx.send((commands, matches)).await.log_err();
}
+ });
+ self.updating_matches = Some((task, rx.clone()));
- 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,
- },
- )
- }
+ cx.spawn(move |picker, mut cx| async move {
+ let Some((commands, matches)) = rx.recv().await else {
+ return;
+ };
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<Picker<Self>>,
+ ) -> 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<Picker<Self>>) {
self.command_palette
.update(cx, |_, cx| cx.emit(DismissEvent))
@@ -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
}
@@ -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<dyn Action>;
@@ -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.
@@ -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, _| {
@@ -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);
}
}
@@ -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 {
@@ -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)
}
}
@@ -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<str>;
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> 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<Picker<Self>>,
+ ) -> bool {
+ false
+ }
+
fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
@@ -98,6 +111,9 @@ impl<D: PickerDelegate> Picker<D> {
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<D: PickerDelegate> Picker<D> {
}
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
- 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<Self>) {
- 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);
@@ -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);
+}
@@ -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<SaveIntent>,
}
+#[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<AppState>,
+ dispatching_keystrokes: Rc<RefCell<Vec<Keystroke>>>,
_subscriptions: Vec<Subscription>,
_apply_leader_updates: Task<Result<()>>,
_observe_current_user: Task<Result<()>>,
@@ -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<Self>) {
+ let mut keystrokes: Vec<Keystroke> = 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 <enter> 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| {