vim: Add :norm
support (#33232)
Closes #21198 Release Notes: - Adds support for `:norm` - Allows for vim and zed style modified keys specified in issue - Vim style <C-w> and zed style <ctrl-w> - Differs from vim in how multi-line is handled - vim is sequential - zed is combinational (with multi-cursor)
This commit is contained in:
parent
c08851a85e
commit
8b0ec287a5
5 changed files with 328 additions and 48 deletions
|
@ -16968,7 +16968,7 @@ impl Editor {
|
|||
now: Instant,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
) -> Option<TransactionId> {
|
||||
self.end_selection(window, cx);
|
||||
if let Some(tx_id) = self
|
||||
.buffer
|
||||
|
@ -16978,7 +16978,10 @@ impl Editor {
|
|||
.insert_transaction(tx_id, self.selections.disjoint_anchors());
|
||||
cx.emit(EditorEvent::TransactionBegun {
|
||||
transaction_id: tx_id,
|
||||
})
|
||||
});
|
||||
Some(tx_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17006,6 +17009,17 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn modify_transaction_selection_history(
|
||||
&mut self,
|
||||
transaction_id: TransactionId,
|
||||
modify: impl FnOnce(&mut (Arc<[Selection<Anchor>]>, Option<Arc<[Selection<Anchor>]>>)),
|
||||
) -> bool {
|
||||
self.selection_history
|
||||
.transaction_mut(transaction_id)
|
||||
.map(modify)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selection_mark_mode {
|
||||
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
|
|
|
@ -6,7 +6,7 @@ use editor::{
|
|||
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
|
||||
display_map::ToDisplayPoint,
|
||||
};
|
||||
use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
|
||||
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
|
||||
use itertools::Itertools;
|
||||
use language::Point;
|
||||
use multi_buffer::MultiBufferRow;
|
||||
|
@ -202,6 +202,7 @@ actions!(
|
|||
ArgumentRequired
|
||||
]
|
||||
);
|
||||
|
||||
/// Opens the specified file for editing.
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
|
@ -209,6 +210,13 @@ struct VimEdit {
|
|||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
struct VimNorm {
|
||||
pub range: Option<CommandRange>,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WrappedAction(Box<dyn Action>);
|
||||
|
||||
|
@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|||
});
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
|
||||
let keystrokes = action
|
||||
.command
|
||||
.chars()
|
||||
.map(|c| Keystroke::parse(&c.to_string()).unwrap())
|
||||
.collect();
|
||||
vim.switch_mode(Mode::Normal, true, window, cx);
|
||||
let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
|
||||
editor.selections.disjoint_anchors()
|
||||
});
|
||||
if let Some(range) = &action.range {
|
||||
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
||||
let range = range.buffer_range(vim, editor, window, cx)?;
|
||||
editor.change_selections(
|
||||
SelectionEffects::no_scroll().nav_history(false),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select_ranges(
|
||||
(range.start.0..=range.end.0)
|
||||
.map(|line| Point::new(line, 0)..Point::new(line, 0)),
|
||||
);
|
||||
},
|
||||
);
|
||||
anyhow::Ok(())
|
||||
});
|
||||
if let Some(Err(err)) = result {
|
||||
log::error!("Error selecting range: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(workspace) = vim.workspace(window) else {
|
||||
return;
|
||||
};
|
||||
let task = workspace.update(cx, |workspace, cx| {
|
||||
workspace.send_keystrokes_impl(keystrokes, window, cx)
|
||||
});
|
||||
let had_range = action.range.is_some();
|
||||
|
||||
cx.spawn_in(window, async move |vim, cx| {
|
||||
task.await;
|
||||
vim.update_in(cx, |vim, window, cx| {
|
||||
vim.update_editor(window, cx, |_, editor, window, cx| {
|
||||
if had_range {
|
||||
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
|
||||
s.select_anchor_ranges([s.newest_anchor().range()]);
|
||||
})
|
||||
}
|
||||
});
|
||||
if matches!(vim.mode, Mode::Insert | Mode::Replace) {
|
||||
vim.normal_before(&Default::default(), window, cx);
|
||||
} else {
|
||||
vim.switch_mode(Mode::Normal, true, window, cx);
|
||||
}
|
||||
vim.update_editor(window, cx, |_, editor, _, cx| {
|
||||
if let Some(first_sel) = initial_selections {
|
||||
if let Some(tx_id) = editor
|
||||
.buffer()
|
||||
.update(cx, |multi, cx| multi.last_transaction_id(cx))
|
||||
{
|
||||
let last_sel = editor.selections.disjoint_anchors();
|
||||
editor.modify_transaction_selection_history(tx_id, |old| {
|
||||
old.0 = first_sel;
|
||||
old.1 = Some(last_sel);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
|
||||
let Some(workspace) = vim.workspace(window) else {
|
||||
return;
|
||||
|
@ -675,14 +758,15 @@ impl VimCommand {
|
|||
} else {
|
||||
return None;
|
||||
};
|
||||
if !args.is_empty() {
|
||||
|
||||
let action = if args.is_empty() {
|
||||
action
|
||||
} else {
|
||||
// if command does not accept args and we have args then we should do no action
|
||||
if let Some(args_fn) = &self.args {
|
||||
args_fn.deref()(action, args)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if let Some(range) = range {
|
||||
self.args.as_ref()?(action, args)?
|
||||
};
|
||||
|
||||
if let Some(range) = range {
|
||||
self.range.as_ref().and_then(|f| f(action, range))
|
||||
} else {
|
||||
Some(action)
|
||||
|
@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
|
|||
save_intent: Some(SaveIntent::Skip),
|
||||
close_pinned: true,
|
||||
}),
|
||||
VimCommand::new(
|
||||
("norm", "al"),
|
||||
VimNorm {
|
||||
command: "".into(),
|
||||
range: None,
|
||||
},
|
||||
)
|
||||
.args(|_, args| {
|
||||
Some(
|
||||
VimNorm {
|
||||
command: args,
|
||||
range: None,
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
})
|
||||
.range(|action, range| {
|
||||
let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
|
||||
action.range.replace(range.clone());
|
||||
Some(Box::new(action))
|
||||
}),
|
||||
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
|
||||
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
|
||||
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
|
||||
|
@ -2298,4 +2403,78 @@ mod test {
|
|||
});
|
||||
assert!(mark.is_none())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_normal_command(cx: &mut TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state(indoc! {"
|
||||
The quick
|
||||
brown« fox
|
||||
jumpsˇ» over
|
||||
the lazy dog
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
jumps worˇd
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
jumps tesˇt
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
lˇaumps test
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.set_shared_state(indoc! {"
|
||||
ˇThe quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes("c i w M y escape").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
Mˇy quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space u").await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
ˇThe quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"});
|
||||
// Once ctrl-v to input character literals is added there should be a test for redo
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|||
}
|
||||
|
||||
impl Vim {
|
||||
fn normal_before(
|
||||
pub(crate) fn normal_before(
|
||||
&mut self,
|
||||
action: &NormalBefore,
|
||||
window: &mut Window,
|
||||
|
|
64
crates/vim/test_data/test_normal_command.json
Normal file
64
crates/vim/test_data/test_normal_command.json
Normal file
|
@ -0,0 +1,64 @@
|
|||
{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"w"}
|
||||
{"Key":"C"}
|
||||
{"Key":"w"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"d"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"_"}
|
||||
{"Key":"w"}
|
||||
{"Key":"c"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Key":"t"}
|
||||
{"Key":"e"}
|
||||
{"Key":"s"}
|
||||
{"Key":"t"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":"_"}
|
||||
{"Key":"l"}
|
||||
{"Key":"v"}
|
||||
{"Key":"l"}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"s"}
|
||||
{"Key":"l"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
|
||||
{"Key":"c"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Key":"M"}
|
||||
{"Key":"y"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"u"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
|
|
@ -32,7 +32,7 @@ use futures::{
|
|||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
oneshot,
|
||||
},
|
||||
future::try_join_all,
|
||||
future::{Shared, try_join_all},
|
||||
};
|
||||
use gpui::{
|
||||
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
|
||||
|
@ -87,7 +87,7 @@ use std::{
|
|||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
cmp,
|
||||
collections::hash_map::DefaultHasher,
|
||||
collections::{VecDeque, hash_map::DefaultHasher},
|
||||
env,
|
||||
hash::{Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
|
@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
|
|||
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
|
||||
>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DispatchingKeystrokes {
|
||||
dispatched: HashSet<Vec<Keystroke>>,
|
||||
queue: VecDeque<Keystroke>,
|
||||
task: Option<Shared<Task<()>>>,
|
||||
}
|
||||
|
||||
/// Collects everything project-related for a certain window opened.
|
||||
/// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
|
||||
///
|
||||
|
@ -1080,7 +1087,7 @@ pub struct Workspace {
|
|||
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
|
||||
database_id: Option<WorkspaceId>,
|
||||
app_state: Arc<AppState>,
|
||||
dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>,
|
||||
dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_apply_leader_updates: Task<Result<()>>,
|
||||
_observe_current_user: Task<Result<()>>,
|
||||
|
@ -2311,49 +2318,65 @@ impl Workspace {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut state = self.dispatching_keystrokes.borrow_mut();
|
||||
if !state.0.insert(action.0.clone()) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
let mut keystrokes: Vec<Keystroke> = action
|
||||
let keystrokes: Vec<Keystroke> = action
|
||||
.0
|
||||
.split(' ')
|
||||
.flat_map(|k| Keystroke::parse(k).log_err())
|
||||
.collect();
|
||||
keystrokes.reverse();
|
||||
let _ = self.send_keystrokes_impl(keystrokes, window, cx);
|
||||
}
|
||||
|
||||
state.1.append(&mut keystrokes);
|
||||
drop(state);
|
||||
pub fn send_keystrokes_impl(
|
||||
&mut self,
|
||||
keystrokes: Vec<Keystroke>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Shared<Task<()>> {
|
||||
let mut state = self.dispatching_keystrokes.borrow_mut();
|
||||
if !state.dispatched.insert(keystrokes.clone()) {
|
||||
cx.propagate();
|
||||
return state.task.clone().unwrap();
|
||||
}
|
||||
|
||||
state.queue.extend(keystrokes);
|
||||
|
||||
let keystrokes = self.dispatching_keystrokes.clone();
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
// limit to 100 keystrokes to avoid infinite recursion.
|
||||
for _ in 0..100 {
|
||||
let Some(keystroke) = keystrokes.borrow_mut().1.pop() else {
|
||||
keystrokes.borrow_mut().0.clear();
|
||||
return Ok(());
|
||||
};
|
||||
cx.update(|window, cx| {
|
||||
let focused = window.focused(cx);
|
||||
window.dispatch_keystroke(keystroke.clone(), cx);
|
||||
if window.focused(cx) != 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"]}
|
||||
// )
|
||||
window.draw(cx).clear();
|
||||
if state.task.is_none() {
|
||||
state.task = Some(
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
// limit to 100 keystrokes to avoid infinite recursion.
|
||||
for _ in 0..100 {
|
||||
let mut state = keystrokes.borrow_mut();
|
||||
let Some(keystroke) = state.queue.pop_front() else {
|
||||
state.dispatched.clear();
|
||||
state.task.take();
|
||||
return;
|
||||
};
|
||||
drop(state);
|
||||
cx.update(|window, cx| {
|
||||
let focused = window.focused(cx);
|
||||
window.dispatch_keystroke(keystroke.clone(), cx);
|
||||
if window.focused(cx) != 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"]}
|
||||
// )
|
||||
window.draw(cx).clear();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
*keystrokes.borrow_mut() = Default::default();
|
||||
anyhow::bail!("over 100 keystrokes passed to send_keystrokes");
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
*keystrokes.borrow_mut() = Default::default();
|
||||
log::error!("over 100 keystrokes passed to send_keystrokes");
|
||||
})
|
||||
.shared(),
|
||||
);
|
||||
}
|
||||
state.task.clone().unwrap()
|
||||
}
|
||||
|
||||
fn save_all_internal(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue