Vim: enable sending multiple keystrokes from custom keybinding (#7965)

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>
This commit is contained in:
N 2024-02-20 17:01:45 -05:00 committed by GitHub
parent 8f5d7db875
commit 8a73bc4c7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 343 additions and 157 deletions

1
Cargo.lock generated
View File

@ -2183,6 +2183,7 @@ dependencies = [
"language", "language",
"menu", "menu",
"picker", "picker",
"postage",
"project", "project",
"release_channel", "release_channel",
"serde", "serde",

View File

@ -28,6 +28,7 @@ ui.workspace = true
util.workspace = true util.workspace = true
workspace.workspace = true workspace.workspace = true
zed_actions.workspace = true zed_actions.workspace = true
postage.workspace = true
[dev-dependencies] [dev-dependencies]
ctor.workspace = true ctor.workspace = true

View File

@ -1,6 +1,7 @@
use std::{ use std::{
cmp::{self, Reverse}, cmp::{self, Reverse},
sync::Arc, sync::Arc,
time::Duration,
}; };
use client::telemetry::Telemetry; use client::telemetry::Telemetry;
@ -9,11 +10,12 @@ use copilot::CommandPaletteFilter;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global, 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 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 ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
use util::ResultExt; use util::ResultExt;
use workspace::{ModalView, Workspace}; use workspace::{ModalView, Workspace};
@ -119,6 +121,10 @@ pub struct CommandPaletteDelegate {
selected_ix: usize, selected_ix: usize,
telemetry: Arc<Telemetry>, telemetry: Arc<Telemetry>,
previous_focus_handle: FocusHandle, previous_focus_handle: FocusHandle,
updating_matches: Option<(
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
)>,
} }
struct Command { struct Command {
@ -138,7 +144,7 @@ impl Clone for Command {
/// Hit count for each command in the palette. /// 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 /// 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. /// 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>); struct HitCounts(HashMap<String, usize>);
impl Global for HitCounts {} impl Global for HitCounts {}
@ -158,89 +164,26 @@ impl CommandPaletteDelegate {
selected_ix: 0, selected_ix: 0,
telemetry, telemetry,
previous_focus_handle, previous_focus_handle,
updating_matches: None,
} }
} }
}
impl PickerDelegate for CommandPaletteDelegate { fn matches_updated(
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> {
"Execute a command...".into()
}
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, &mut self,
query: String, query: String,
mut commands: Vec<Command>,
mut matches: Vec<StringMatch>,
cx: &mut ViewContext<Picker<Self>>, cx: &mut ViewContext<Picker<Self>>,
) -> gpui::Task<()> { ) {
let mut commands = self.all_commands.clone(); self.updating_matches.take();
cx.spawn(move |picker, mut cx| async move { let mut intercept_result =
cx.read_global::<HitCounts, _>(|hit_counts, _| { if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
commands.sort_by_key(|action| { (interceptor.0)(&query, cx)
(
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 { } else {
fuzzy::match_strings( None
&candidates,
&query,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
}; };
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() { if parse_zed_link(&query).is_some() {
intercept_result = Some(CommandInterceptResult { intercept_result = Some(CommandInterceptResult {
action: OpenZedUrl { url: query.clone() }.boxed_clone(), action: OpenZedUrl { url: query.clone() }.boxed_clone(),
@ -248,7 +191,6 @@ impl PickerDelegate for CommandPaletteDelegate {
positions: vec![], positions: vec![],
}) })
} }
}
if let Some(CommandInterceptResult { if let Some(CommandInterceptResult {
action, action,
@ -276,23 +218,133 @@ impl PickerDelegate for CommandPaletteDelegate {
}, },
) )
} }
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);
}
}
}
impl PickerDelegate for CommandPaletteDelegate {
type ListItem = ListItem;
fn placeholder_text(&self) -> Arc<str> {
"Execute a command...".into()
}
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 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(),
)
});
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 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;
};
picker picker
.update(&mut cx, |picker, _| { .update(&mut cx, |picker, cx| {
let delegate = &mut picker.delegate; picker
delegate.commands = commands; .delegate
delegate.matches = matches; .matches_updated(query, commands, matches, cx)
if delegate.matches.is_empty() {
delegate.selected_ix = 0;
} else {
delegate.selected_ix =
cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
}
}) })
.log_err(); .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>>) { fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
self.command_palette self.command_palette
.update(cx, |_, cx| cx.emit(DismissEvent)) .update(cx, |_, cx| cx.emit(DismissEvent))

View File

@ -147,7 +147,7 @@ impl EditorTestContext {
self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text)); self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
let keystroke = Keystroke::parse(keystroke_text).unwrap(); 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 keystroke_under_test_handle
} }

View File

@ -39,7 +39,7 @@ use std::any::{Any, TypeId};
/// } /// }
/// register_action!(Paste); /// register_action!(Paste);
/// ``` /// ```
pub trait Action: 'static { pub trait Action: 'static + Send {
/// Clone the action into a new box /// Clone the action into a new box
fn boxed_clone(&self) -> Box<dyn Action>; fn boxed_clone(&self) -> Box<dyn Action>;

View File

@ -335,7 +335,7 @@ impl TestAppContext {
.map(Keystroke::parse) .map(Keystroke::parse)
.map(Result::unwrap) .map(Result::unwrap)
{ {
self.dispatch_keystroke(window, keystroke.into(), false); self.dispatch_keystroke(window, keystroke.into());
} }
self.background_executor.run_until_parked() self.background_executor.run_until_parked()
@ -347,21 +347,16 @@ impl TestAppContext {
/// This will also run the background executor until it's parked. /// This will also run the background executor until it's parked.
pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) { 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() self.background_executor.run_until_parked()
} }
/// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`) /// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`)
pub fn dispatch_keystroke( pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) {
&mut self, self.update_window(window, |_, cx| cx.dispatch_keystroke(keystroke))
window: AnyWindowHandle, .unwrap();
keystroke: Keystroke,
is_held: bool,
) {
self.test_window(window)
.simulate_keystroke(keystroke, is_held)
} }
/// Returns the `TestWindow` backing the given handle. /// Returns the `TestWindow` backing the given handle.

View File

@ -491,8 +491,8 @@ mod test {
.update(cx, |test_view, cx| cx.focus(&test_view.focus_handle)) .update(cx, |test_view, cx| cx.focus(&test_view.focus_handle))
.unwrap(); .unwrap();
cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap(), false); cx.dispatch_keystroke(*window, Keystroke::parse("a").unwrap());
cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap(), false); cx.dispatch_keystroke(*window, Keystroke::parse("ctrl-g").unwrap());
window window
.update(cx, |test_view, _| { .update(cx, |test_view, _| {

View File

@ -412,7 +412,7 @@ impl PlatformInputHandler {
.flatten() .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); self.handler.replace_text_in_range(None, input, cx);
} }
} }

View File

@ -1,7 +1,7 @@
use crate::{ use crate::{
px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke, px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, Pixels, PlatformAtlas,
Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, Size,
Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions,
}; };
use collections::HashMap; use collections::HashMap;
use parking_lot::Mutex; use parking_lot::Mutex;
@ -112,41 +112,6 @@ impl TestWindow {
self.0.lock().input_callback = Some(callback); self.0.lock().input_callback = Some(callback);
result 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 { impl PlatformWindow for TestWindow {

View File

@ -950,7 +950,7 @@ impl<'a> WindowContext<'a> {
/// Produces a new frame and assigns it to `rendered_frame`. To actually show /// Produces a new frame and assigns it to `rendered_frame`. To actually show
/// the contents of the new [Scene], use [present]. /// 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.dirty.set(false);
self.window.drawing = true; self.window.drawing = true;
@ -1099,6 +1099,38 @@ impl<'a> WindowContext<'a> {
self.window.needs_present.set(false); 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. /// Dispatch a mouse or keyboard event on the window.
pub fn dispatch_event(&mut self, event: PlatformInput) -> bool { pub fn dispatch_event(&mut self, event: PlatformInput) -> bool {
self.window.last_input_timestamp.set(Instant::now()); self.window.last_input_timestamp.set(Instant::now());
@ -1423,7 +1455,7 @@ impl<'a> WindowContext<'a> {
if !input.is_empty() { if !input.is_empty() {
if let Some(mut input_handler) = self.window.platform_window.take_input_handler() { 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) self.window.platform_window.set_input_handler(input_handler)
} }
} }

View File

@ -4,7 +4,7 @@ use gpui::{
FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task, FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
UniformListScrollHandle, View, ViewContext, WindowContext, 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 ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
use workspace::ModalView; use workspace::ModalView;
@ -40,6 +40,19 @@ pub trait PickerDelegate: Sized + 'static {
fn placeholder_text(&self) -> Arc<str>; fn placeholder_text(&self) -> Arc<str>;
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>; 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 confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
fn dismissed(&mut self, 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, is_modal: true,
}; };
this.update_matches("".to_string(), cx); 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 this
} }
@ -197,15 +213,24 @@ impl<D: PickerDelegate> Picker<D> {
} }
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) { 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) self.confirm_on_update = Some(false)
} else { } else {
self.pending_update_matches.take();
self.delegate.confirm(false, cx); self.delegate.confirm(false, cx);
} }
} }
fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) { 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) self.confirm_on_update = Some(true)
} else { } else {
self.delegate.confirm(true, cx); self.delegate.confirm(true, cx);

View File

@ -884,3 +884,70 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
rename_request.next().await.unwrap(); rename_request.next().await.unwrap();
cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal) 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);
}

View File

@ -29,10 +29,10 @@ use gpui::{
actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView, actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView,
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div,
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke,
ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point,
Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext,
WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions, VisualContext, WeakView, WindowBounds, WindowContext, WindowHandle, WindowOptions,
}; };
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem}; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
use itertools::Itertools; use itertools::Itertools;
@ -59,10 +59,11 @@ pub use status_bar::StatusItemView;
use std::{ use std::{
any::TypeId, any::TypeId,
borrow::Cow, borrow::Cow,
cell::RefCell,
cmp, env, cmp, env,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Weak, rc::Rc,
sync::{atomic::AtomicUsize, Arc}, sync::{atomic::AtomicUsize, Arc, Weak},
time::Duration, time::Duration,
}; };
use theme::{ActiveTheme, SystemAppearance, ThemeSettings}; use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
@ -157,6 +158,9 @@ pub struct CloseAllItemsAndPanes {
pub save_intent: Option<SaveIntent>, pub save_intent: Option<SaveIntent>,
} }
#[derive(Clone, Deserialize, PartialEq)]
pub struct SendKeystrokes(pub String);
impl_actions!( impl_actions!(
workspace, workspace,
[ [
@ -168,6 +172,7 @@ impl_actions!(
Save, Save,
SaveAll, SaveAll,
SwapPaneInDirection, SwapPaneInDirection,
SendKeystrokes,
] ]
); );
@ -499,6 +504,7 @@ pub struct Workspace {
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
database_id: WorkspaceId, database_id: WorkspaceId,
app_state: Arc<AppState>, app_state: Arc<AppState>,
dispatching_keystrokes: Rc<RefCell<Vec<Keystroke>>>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
_apply_leader_updates: Task<Result<()>>, _apply_leader_updates: Task<Result<()>>,
_observe_current_user: Task<Result<()>>, _observe_current_user: Task<Result<()>>,
@ -754,6 +760,7 @@ impl Workspace {
project: project.clone(), project: project.clone(),
follower_states: Default::default(), follower_states: Default::default(),
last_leaders_by_pane: Default::default(), last_leaders_by_pane: Default::default(),
dispatching_keystrokes: Default::default(),
window_edited: false, window_edited: false,
active_call, active_call,
database_id: workspace_id, database_id: workspace_id,
@ -1252,6 +1259,46 @@ impl Workspace {
.detach_and_log_err(cx); .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( fn save_all_internal(
&mut self, &mut self,
mut save_intent: SaveIntent, 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_inactive_items_and_panes))
.on_action(cx.listener(Self::close_all_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::save_all))
.on_action(cx.listener(Self::send_keystrokes))
.on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(Self::add_folder_to_project))
.on_action(cx.listener(Self::follow_next_collaborator)) .on_action(cx.listener(Self::follow_next_collaborator))
.on_action(cx.listener(|workspace, _: &Unfollow, cx| { .on_action(cx.listener(|workspace, _: &Unfollow, cx| {