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:
parent
8f5d7db875
commit
8a73bc4c7d
13 changed files with 343 additions and 157 deletions
|
@ -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
|
||||
};
|
||||
|
||||
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<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))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue