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:
AidanV 2025-07-23 22:06:05 -07:00 committed by GitHub
parent c08851a85e
commit 8b0ec287a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 328 additions and 48 deletions

View file

@ -16968,7 +16968,7 @@ impl Editor {
now: Instant, now: Instant,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) -> Option<TransactionId> {
self.end_selection(window, cx); self.end_selection(window, cx);
if let Some(tx_id) = self if let Some(tx_id) = self
.buffer .buffer
@ -16978,7 +16978,10 @@ impl Editor {
.insert_transaction(tx_id, self.selections.disjoint_anchors()); .insert_transaction(tx_id, self.selections.disjoint_anchors());
cx.emit(EditorEvent::TransactionBegun { cx.emit(EditorEvent::TransactionBegun {
transaction_id: tx_id, 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>) { pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
if self.selection_mark_mode { if self.selection_mark_mode {
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {

View file

@ -6,7 +6,7 @@ use editor::{
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}, actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
display_map::ToDisplayPoint, 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 itertools::Itertools;
use language::Point; use language::Point;
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
@ -202,6 +202,7 @@ actions!(
ArgumentRequired ArgumentRequired
] ]
); );
/// Opens the specified file for editing. /// Opens the specified file for editing.
#[derive(Clone, PartialEq, Action)] #[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)] #[action(namespace = vim, no_json, no_register)]
@ -209,6 +210,13 @@ struct VimEdit {
pub filename: String, 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)] #[derive(Debug)]
struct WrappedAction(Box<dyn Action>); 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| { Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
let Some(workspace) = vim.workspace(window) else { let Some(workspace) = vim.workspace(window) else {
return; return;
@ -675,14 +758,15 @@ impl VimCommand {
} else { } else {
return None; return None;
}; };
if !args.is_empty() {
// if command does not accept args and we have args then we should do no action let action = if args.is_empty() {
if let Some(args_fn) = &self.args { action
args_fn.deref()(action, args)
} else { } else {
None // if command does not accept args and we have args then we should do no action
} self.args.as_ref()?(action, args)?
} else if let Some(range) = range { };
if let Some(range) = range {
self.range.as_ref().and_then(|f| f(action, range)) self.range.as_ref().and_then(|f| f(action, range))
} else { } else {
Some(action) Some(action)
@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
save_intent: Some(SaveIntent::Skip), save_intent: Some(SaveIntent::Skip),
close_pinned: true, 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::ActivateNextItem).count(),
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(), VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(), VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
@ -2298,4 +2403,78 @@ mod test {
}); });
assert!(mark.is_none()) 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
}
} }

View file

@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
} }
impl Vim { impl Vim {
fn normal_before( pub(crate) fn normal_before(
&mut self, &mut self,
action: &NormalBefore, action: &NormalBefore,
window: &mut Window, window: &mut Window,

View 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"}}

View file

@ -32,7 +32,7 @@ use futures::{
mpsc::{self, UnboundedReceiver, UnboundedSender}, mpsc::{self, UnboundedReceiver, UnboundedSender},
oneshot, oneshot,
}, },
future::try_join_all, future::{Shared, try_join_all},
}; };
use gpui::{ use gpui::{
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
@ -87,7 +87,7 @@ use std::{
borrow::Cow, borrow::Cow,
cell::RefCell, cell::RefCell,
cmp, cmp,
collections::hash_map::DefaultHasher, collections::{VecDeque, hash_map::DefaultHasher},
env, env,
hash::{Hash, Hasher}, hash::{Hash, Hasher},
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
) -> oneshot::Receiver<Option<Vec<PathBuf>>>, ) -> 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. /// 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`. /// 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)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
database_id: Option<WorkspaceId>, database_id: Option<WorkspaceId>,
app_state: Arc<AppState>, app_state: Arc<AppState>,
dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>, dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
_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<()>>,
@ -2311,30 +2318,42 @@ impl Workspace {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let mut state = self.dispatching_keystrokes.borrow_mut(); let keystrokes: Vec<Keystroke> = action
if !state.0.insert(action.0.clone()) {
cx.propagate();
return;
}
let mut keystrokes: Vec<Keystroke> = action
.0 .0
.split(' ') .split(' ')
.flat_map(|k| Keystroke::parse(k).log_err()) .flat_map(|k| Keystroke::parse(k).log_err())
.collect(); .collect();
keystrokes.reverse(); let _ = self.send_keystrokes_impl(keystrokes, window, cx);
}
state.1.append(&mut keystrokes); pub fn send_keystrokes_impl(
drop(state); &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(); let keystrokes = self.dispatching_keystrokes.clone();
if state.task.is_none() {
state.task = Some(
window window
.spawn(cx, async move |cx| { .spawn(cx, async move |cx| {
// limit to 100 keystrokes to avoid infinite recursion. // limit to 100 keystrokes to avoid infinite recursion.
for _ in 0..100 { for _ in 0..100 {
let Some(keystroke) = keystrokes.borrow_mut().1.pop() else { let mut state = keystrokes.borrow_mut();
keystrokes.borrow_mut().0.clear(); let Some(keystroke) = state.queue.pop_front() else {
return Ok(()); state.dispatched.clear();
state.task.take();
return;
}; };
drop(state);
cx.update(|window, cx| { cx.update(|window, cx| {
let focused = window.focused(cx); let focused = window.focused(cx);
window.dispatch_keystroke(keystroke.clone(), cx); window.dispatch_keystroke(keystroke.clone(), cx);
@ -2347,13 +2366,17 @@ impl Workspace {
// ) // )
window.draw(cx).clear(); window.draw(cx).clear();
} }
})?; })
.ok();
} }
*keystrokes.borrow_mut() = Default::default(); *keystrokes.borrow_mut() = Default::default();
anyhow::bail!("over 100 keystrokes passed to send_keystrokes"); log::error!("over 100 keystrokes passed to send_keystrokes");
}) })
.detach_and_log_err(cx); .shared(),
);
}
state.task.clone().unwrap()
} }
fn save_all_internal( fn save_all_internal(