@@ -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
+ }
}
@@ -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);
+ }
+
+ 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.1.append(&mut keystrokes);
- drop(state);
+ 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(