feat(vim): Built-in Vim mode modelled on IdeaVim (#55)

Built-in Vim emulation on @replit/codemirror-vim (approved dependency), modelled on IdeaVim: `vim.toggle` (Tools › Vim, status widget, Settings › Editor › Vim), mode widget (NORMAL / INSERT / VISUAL / V-LINE / V-BLOCK / REPLACE), IdeaVim-style shortcut conflict handlers (Vim / IDE per key) through a generic `registerKeyClaim()` in the keymap dispatcher, Escape closing popups first, a safe subset of ~/.openideavimrc / ~/.ideavimrc (set, map variants, mapleader, :action / <Action>()), Ex commands :w :wa :q :qa :wq :x :action. Covers file editors, diff editable side and merge result via a new `embeddedEditorExtensions` extension point.

Verified in the app on macOS: motions/operators, i/Esc/V mode widget, :w saves, Ctrl+R redoes in Vim without running Run, toggling off restores plain editing. Not verified on Windows/Linux.

Fixes #27

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 11:43:37 +08:00
1 джерело cac95273d3
коміт 7146779f6c
24 files changed
+1458 -5

No files matched your search

+1
Переглянути файл
@@ -37,6 +37,7 @@
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "6.4.0",
"@tanstack/react-virtual": "^3.14.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.8.3",
Створено
+32
Переглянути файл
@@ -47,6 +47,9 @@ importers:
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
'@replit/codemirror-vim':
specifier: 6.4.0
version: 6.4.0(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])
'@tanstack/react-virtual':
specifier: ^3.14.21
version: 3.14.21([email protected]([email protected]))([email protected])
@@ -571,6 +574,18 @@ packages:
engines: {node: '>=20'}
hasBin: true
'@replit/[email protected]':
resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==}
'@replit/[email protected]':
resolution: {integrity: sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A==}
peerDependencies:
'@codemirror/commands': 6.x.x
'@codemirror/language': 6.x.x
'@codemirror/search': 6.x.x
'@codemirror/state': 6.x.x
'@codemirror/view': 6.x.x
'@rolldown/[email protected]':
resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -619,12 +634,14 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==}
@@ -645,6 +662,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==}
@@ -1302,18 +1320,21 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
@@ -2382,6 +2403,17 @@ snapshots:
dependencies:
playwright: 1.64.1
'@replit/[email protected]': {}
'@replit/[email protected](@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected])':
dependencies:
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.13.1
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.14
'@replit/codemirror-vim-core': 0.1.0
'@rolldown/[email protected]':
optional: true
+3
Переглянути файл
@@ -630,6 +630,8 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("db.showToolWindow", "Database"),
act("db.newDataSource", "New Data Source…"),
act("db.openConsole", "Open Query Console"),
SEP,
act("vim.toggle", "Vim"),
],
));
@@ -1432,6 +1434,7 @@ mod tests {
"code.quickDefinition",
"code.quickTypeDefinition",
"localHistory.recentChanges",
"vim.toggle",
"editor.resetFontSize",
"diff.compareWithClipboard",
"view.quickSwitchScheme",
+50 -2
Переглянути файл
@@ -17,8 +17,8 @@
* - Right-hand overview marks: `scrollbarMarks`; save hooks:
* `registerSaveParticipant`; Back/Forward history: `getNavigationHistory`.
*/
import { Annotation, Facet, type EditorState, type Extension, type RangeSet, type Transaction } from "@codemirror/state";
import { GutterMarker, type EditorView } from "@codemirror/view";
import { Annotation, Compartment, Facet, type EditorState, type Extension, type RangeSet, type Transaction } from "@codemirror/state";
import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view";
import type { LanguageDescription } from "@codemirror/language";
import { create } from "zustand";
import { maybeEditorService } from "@/workbench/contributions";
@@ -45,6 +45,11 @@ export interface EditorContribution {
order?: number;
/** Return extensions for one editor instance (called on creation). */
extensions: (ctx: EditorDocContext) => Extension | Extension[];
/**
* Also applies to editable editors outside editor/core (the diff viewer's
* editable side, the merge result), which get `doc: -1`. Default false.
*/
embedded?: boolean;
}
export interface TextEditorHandle {
@@ -85,6 +90,49 @@ export function editorContributions(): EditorContribution[] {
return [...useEditorRegistry.getState().contributions.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
}
const embeddedCompartment = new Compartment();
const embeddedViews = new Map<EditorView, EditorDocContext>();
function embeddedContributions(ctx: EditorDocContext): Extension[] {
const out: Extension[] = [];
for (const c of editorContributions()) {
if (!c.embedded) continue;
try {
out.push(c.extensions(ctx));
} catch (e) {
console.error(`editor contribution ${c.id} failed`, e);
}
}
return out;
}
/**
* Contributions flagged `embedded` for an editable editor that editor/core
* does not own (diff viewer, merge result). Reconfigured live when
* contributions change.
*/
export function embeddedEditorExtensions(path: string): Extension {
const ctx: EditorDocContext = { doc: -1, path, language: "", uiMode: null, readOnly: false };
return [
embeddedCompartment.of(embeddedContributions(ctx)),
ViewPlugin.define((view) => {
embeddedViews.set(view, ctx);
return { destroy: () => void embeddedViews.delete(view) };
}),
];
}
const embeddedOf = (m: Map<string, EditorContribution>) => [...m.values()].filter((c) => c.embedded);
useEditorRegistry.subscribe((s, prev) => {
if (s.contributions === prev.contributions) return;
const now = embeddedOf(s.contributions);
const before = embeddedOf(prev.contributions);
if (now.length === before.length && now.every((c, i) => c === before[i])) return;
for (const [view, ctx] of embeddedViews)
view.dispatch({ effects: embeddedCompartment.reconfigure(embeddedContributions(ctx)) });
});
export function activeEditor(): TextEditorHandle | null {
return useEditorRegistry.getState().active;
}
+3
Переглянути файл
@@ -35,6 +35,7 @@ import { activate as services } from "./services";
import { activate as github } from "./github";
import { activate as httpClient } from "./http-client";
import { activate as database } from "./database";
import { activate as vim, activateSettingsPages as vimPages } from "./vim";
type Activate = () => void | (() => void);
@@ -71,6 +72,7 @@ const features: Array<[string, Activate]> = [
["github", github],
["http-client", httpClient],
["database", database],
["vim", vim],
];
export function activateFeatures(): () => void {
@@ -100,6 +102,7 @@ export function activateSettingsWindowFeatures(parent: string): () => void {
["todo", todoPages],
["plugins", pluginsPages],
["tools", toolsPages],
["vim", vimPages],
];
const disposers: Array<() => void> = [];
for (const [name, activate] of pages) {
+2 -2
Переглянути файл
@@ -14,7 +14,7 @@ import {
unifiedMergeView,
type Chunk,
} from "@codemirror/merge";
import { attachDocument, type DocumentAttachment } from "@/editor/api";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setContext } from "@/platform/context";
import { call, commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
@@ -223,7 +223,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
viewerBase({ readOnly: !editable }),
lang,
att?.extension ?? [],
editable ? editableExtensions() : [],
editable ? [editableExtensions(), embeddedEditorExtensions(path)] : [],
notes ? diffAnnotations((data.annotations ?? []).filter((n) => n.side === notes), styles) : [],
];
let views: Views;
+3 -1
Переглянути файл
@@ -7,6 +7,7 @@ import { history, historyKeymap } from "@codemirror/commands";
import { keymap } from "@codemirror/view";
import { unifiedMergeView } from "@codemirror/merge";
import type { MergeSides } from "@/bindings";
import { embeddedEditorExtensions } from "@/editor/api";
import { useTxTemplate } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { Button, IconButton, Spinner, T } from "@/ui";
@@ -202,6 +203,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
viewerBase({ readOnly: false }),
history(),
keymap.of(historyKeymap),
embeddedEditorExtensions(path),
lang,
conflictField.init((s) => ({ conflicts: start.conflicts, deco: decorate(s, start.conflicts) })),
resultTheme,
@@ -218,7 +220,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
result.destroy();
resultView.current = null;
};
}, [state, lang]);
}, [state, lang, path]);
const goto = (dir: 1 | -1) => {
const v = resultView.current;
+68
Переглянути файл
@@ -0,0 +1,68 @@
.status {
min-width: 7ch;
justify-content: center;
font-weight: 600;
}
.status[data-mode="insert"] {
color: var(--success);
}
.status[data-mode="visual"] {
color: var(--info);
}
.status[data-mode="replace"] {
color: var(--warning);
}
.off {
color: var(--fg-subtle);
font-weight: normal;
}
.page {
display: flex;
flex-direction: column;
gap: 16px;
color: var(--fg);
font-size: var(--fs-ui);
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--fg-muted);
}
.hint {
margin: 0;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.mono {
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.ignored {
margin: 0;
padding-left: 16px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border-strong);
}
.table th {
text-align: left;
font-weight: normal;
color: var(--fg-muted);
padding: 4px 8px;
border-bottom: 1px solid var(--border-strong);
}
.table td {
padding: 2px 8px;
height: 28px;
}
+142
Переглянути файл
@@ -0,0 +1,142 @@
/** Settings › Editor › Vim: on/off, the rc file, and IdeaVim's "Shortcut conflicts" table. */
import { useEffect, useMemo } from "react";
import { useActions } from "@/platform/actions";
import { effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap";
import { Button, Checkbox, Select, T } from "@/ui";
import { conflictRows, VIM_KEYS } from "./conflicts";
import { loadRcFile, useVimRc } from "./rcFile";
import { updateVimSettings, useVimSettings, type Handler } from "./settings";
import styles from "./Vim.module.css";
const HANDLERS = [
{ value: "vim" as const, label: "Vim" },
{ value: "ide" as const, label: "IDE" },
];
export function VimSettingsPage() {
const s = useVimSettings();
const rc = useVimRc();
const keymap = useKeymap();
const actions = useActions((a) => a.actions);
// eslint-disable-next-line react-hooks/exhaustive-deps -- the keymap store is the input of effectiveBindings()
const rows = useMemo(() => conflictRows(effectiveBindings(), s.handlers), [keymap, s.handlers]);
useEffect(() => {
if (!useVimRc.getState().loaded) void loadRcFile();
}, []);
const setHandler = (stroke: string, handler: Handler) => {
const handlers = { ...s.handlers };
if (VIM_KEYS.find((k) => k.stroke === stroke)?.handler === handler) delete handlers[stroke];
else handlers[stroke] = handler;
updateVimSettings({ handlers });
};
return (
<div className={styles.page}>
<Checkbox
checked={s.enabled}
onChange={(enabled) => updateVimSettings({ enabled })}
label="Enable Vim emulation"
/>
<section className={styles.section} aria-label="Vim rc file">
<div className={styles.header}>
<span>
<T>Configuration file</T>
</span>
<Button size="small" onClick={() => void loadRcFile()}>
Reload
</Button>
</div>
<p className={styles.hint}>
{rc.path ? (
<>
<code className={styles.mono}>{rc.path}</code> · {rc.mappings} <T>mappings</T>
</>
) : (
<T>No ~/.openideavimrc or ~/.ideavimrc found.</T>
)}
</p>
<p className={styles.hint}>
<T>
Supported: set options, map / nmap / imap / vmap / noremap variants, let mapleader, and :action /
&lt;Action&gt;() mappings that run IDE actions. :w saves, :q closes the editor tab.
</T>
</p>
{rc.ignored.length > 0 && (
<ul className={styles.ignored} aria-label="Ignored lines">
{rc.ignored.map((i) => (
<li key={i.line}>
<span className={styles.mono}>
{i.line}: {i.text}
</span>{" "}
— {i.reason}
</li>
))}
</ul>
)}
</section>
<section className={styles.section} aria-label="Shortcut conflicts">
<div className={styles.header}>
<span>
<T>Shortcut conflicts</T>
</span>
</div>
<p className={styles.hint}>
<T>
Keys Vim also handles. With Vim as the handler the key goes to the editor while Vim mode is on and an editor
has focus; with IDE, an enabled IDE action runs first.
</T>
</p>
<table className={styles.table}>
<thead>
<tr>
<th>
<T>Shortcut</T>
</th>
<th>
<T>IDE action</T>
</th>
<th>
<T>Vim</T>
</th>
<th>
<T>Handler</T>
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.stroke}>
<td className={styles.mono}>{formatBinding(r.stroke)}</td>
<td>{r.actions.length ? r.actions.map((id) => actions.get(id)?.title ?? id).join(", ") : "—"}</td>
<td>
<T>{r.vim}</T>
</td>
<td>
<Select
value={r.current}
options={HANDLERS}
onChange={(v) => setHandler(r.stroke, v)}
aria-label={`Handler for ${r.stroke}`}
/>
</td>
</tr>
))}
</tbody>
</table>
<div>
<Button
size="small"
onClick={() => updateVimSettings({ handlers: {} })}
disabled={!Object.keys(s.handlers).length}
>
Reset to Defaults
</Button>
</div>
</section>
</div>
);
}
+44
Переглянути файл
@@ -0,0 +1,44 @@
/** IdeaVim's status bar widget: the current mode, and a menu to toggle Vim and reach its settings. */
import { useRef, useState } from "react";
import clsx from "clsx";
import { BarButton, Menu } from "@/ui";
import { modeLabel, useVimMode } from "./mode";
import { setVimEnabled, useVimSettings } from "./settings";
import styles from "./Vim.module.css";
export function VimStatus() {
const enabled = useVimSettings((s) => s.enabled);
const mode = useVimMode();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const label = enabled ? modeLabel(mode) : "Vim";
return (
<>
<BarButton
ref={ref}
mono
className={clsx(styles.status, !enabled && styles.off)}
data-mode={enabled ? mode.mode : undefined}
tooltip={enabled ? "Vim mode" : "Vim (disabled)"}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{label}
</BarButton>
<Menu
open={open}
onClose={() => setOpen(false)}
anchor={ref}
placement="top-end"
aria-label="Vim"
items={[
{ id: "enabled", label: "Enabled", checked: enabled, run: () => setVimEnabled(!enabled) },
{ kind: "separator" },
{ kind: "action", action: "vim.reloadRc", label: "Reload .ideavimrc" },
{ kind: "action", action: "vim.settings", label: "Settings…" },
]}
/>
</>
);
}
+112
Переглянути файл
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAction } from "@/platform/actions";
import { installKeyboardDispatcher, registerKeyClaim, useKeymap } from "@/platform/keymap";
import { conflictRows, handlerFor, VIM_EDITOR_ATTR, VIM_KEYS, vimKeyClaim } from "../conflicts";
import { DEFAULT_VIM_SETTINGS, parseVimSettings, type VimSettings } from "../settings";
describe("vim shortcut conflicts", () => {
it("defaults like IdeaVim: typical Vim keys to Vim, Escape to the IDE first", () => {
for (const k of ["Ctrl+R", "Ctrl+V", "Ctrl+D", "Ctrl+W", "Ctrl+U", "Ctrl+["]) expect(handlerFor(k, {})).toBe("vim");
expect(handlerFor("Escape", {})).toBe("ide");
expect(handlerFor("Ctrl+C", {})).toBe("ide");
expect(handlerFor("Meta+R", {})).toBeNull();
expect(handlerFor("Ctrl+R", { "Ctrl+R": "ide" })).toBe("ide");
});
it("lists the IDE actions bound to each Vim key", () => {
const rows = conflictRows(
[
["Ctrl+R", "run.run"],
["Ctrl+D", "run.debug"],
["Ctrl+W Ctrl+X", "t.chord"],
["Meta+R", "other"],
],
{ "Ctrl+D": "ide" },
);
expect(rows).toHaveLength(VIM_KEYS.length);
const row = (s: string) => rows.find((r) => r.stroke === s)!;
expect(row("Ctrl+R")).toMatchObject({ actions: ["run.run"], current: "vim" });
expect(row("Ctrl+D")).toMatchObject({ actions: ["run.debug"], current: "ide" });
expect(row("Ctrl+W").actions).toEqual(["t.chord"]);
expect(row("Ctrl+V").actions).toEqual([]);
});
it("parses stored settings defensively", () => {
expect(parseVimSettings(null)).toEqual(DEFAULT_VIM_SETTINGS);
expect(parseVimSettings("{bad")).toEqual(DEFAULT_VIM_SETTINGS);
expect(parseVimSettings('{"enabled":true,"handlers":{"Ctrl+R":"ide","Ctrl+V":"nope"}}')).toEqual({
enabled: true,
handlers: { "Ctrl+R": "ide" },
});
});
});
describe("keyboard dispatcher with Vim mode", () => {
let settings: VimSettings;
let disposers: Array<() => void> = [];
const run = vi.fn();
const esc = vi.fn();
let editor: HTMLElement;
let plain: HTMLElement;
beforeEach(() => {
run.mockReset();
esc.mockReset();
settings = { enabled: true, handlers: {} };
document.body.innerHTML = `<div class="cm-editor" ${VIM_EDITOR_ATTR}="true"><div class="cm-content" tabindex="0"></div></div><div id="plain" tabindex="0"></div>`;
editor = document.querySelector(".cm-content")!;
plain = document.querySelector("#plain")!;
useKeymap.setState({ overrides: { "t.ctrlR": ["Ctrl+R"], "t.escape": ["Escape"] } });
disposers = [
registerAction({ id: "t.ctrlR", title: "Ctrl R", run }),
registerAction({ id: "t.escape", title: "Escape", run: esc }),
registerKeyClaim(vimKeyClaim(() => settings)),
installKeyboardDispatcher(window),
];
});
afterEach(() => {
disposers.reverse().forEach((d) => d());
useKeymap.setState({ overrides: {} });
});
const press = (target: HTMLElement, code: string, init: KeyboardEventInit) => {
const e = new KeyboardEvent("keydown", { code, bubbles: true, cancelable: true, ...init });
target.dispatchEvent(e);
return e;
};
const ctrlR = (target: HTMLElement) => press(target, "KeyR", { key: "r", ctrlKey: true });
it("leaves a Vim-handled key to the focused Vim editor", async () => {
const e = ctrlR(editor);
await Promise.resolve();
expect(run).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("runs the IDE action when the handler is IDE", async () => {
settings.handlers["Ctrl+R"] = "ide";
const e = ctrlR(editor);
await Promise.resolve();
expect(run).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(true);
});
it("runs the IDE action when Vim mode is off or focus is outside a Vim editor", async () => {
ctrlR(plain);
settings.enabled = false;
ctrlR(editor);
await Promise.resolve();
expect(run).toHaveBeenCalledTimes(2);
});
it("gives Escape to the IDE first by default, to Vim when configured", async () => {
press(editor, "Escape", { key: "Escape" });
await Promise.resolve();
expect(esc).toHaveBeenCalledOnce();
settings.handlers.Escape = "vim";
const e = press(editor, "Escape", { key: "Escape" });
await Promise.resolve();
expect(esc).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(false);
});
});
+93
Переглянути файл
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { PRESETS } from "@/platform/keymap";
import { exOfRhs, IDEA_ACTIONS, parseVimrc, resolveActionId } from "../rc";
const RC = `
" OpenIDEA vimrc
set textwidth=100 nopcre
set ideajoin surround
let mapleader = " "
nnoremap <leader>f :action FindInPath<CR>
nmap <silent> gd <Action>(GotoDeclaration)
map <leader>r <Action>(ReformatCode)
imap jk <Esc>
vnoremap < <gv
noremap H ^
xmap <leader>c <Action>(code.commentLine)
let g:mapleader = ","
nmap <leader>w :w<CR>
source ~/.vimrc
nnoremap
Plug 'tpope/vim-surround'
`;
describe("ideavimrc parsing", () => {
const { commands, ignored } = parseVimrc(RC);
it("parses set options, keeping only ones the engine supports", () => {
expect(commands.filter((c) => c.kind === "set")).toEqual([
{ kind: "set", name: "textwidth", value: "100" },
{ kind: "set", name: "pcre", value: false },
]);
expect(ignored.map((i) => i.reason)).toEqual(
expect.arrayContaining(["option not supported: ideajoin", "option not supported: surround"]),
);
});
it("parses map variants with modes, noremap and the leader in effect at that line", () => {
const maps = commands.filter((c) => c.kind !== "set");
expect(maps).toEqual([
{ kind: "mapEx", mode: "normal", lhs: "<Space>f", ex: "action FindInPath" },
{ kind: "mapEx", mode: "normal", lhs: "gd", ex: "action GotoDeclaration" },
{ kind: "mapEx", mode: undefined, lhs: "<Space>r", ex: "action ReformatCode" },
{ kind: "map", mode: "insert", noremap: false, lhs: "jk", rhs: "<Esc>" },
{ kind: "map", mode: "visual", noremap: true, lhs: "<", rhs: "<gv" },
{ kind: "map", mode: undefined, noremap: true, lhs: "H", rhs: "^" },
{ kind: "mapEx", mode: "visual", lhs: "<Space>c", ex: "action code.commentLine" },
{ kind: "mapEx", mode: "normal", lhs: ",w", ex: "w" },
]);
});
it("reports everything else as ignored, never executed", () => {
const reasons = ignored.map((i) => `${i.line}:${i.reason}`);
expect(reasons).toContain("15:unsupported command: source");
expect(reasons).toContain("16:mapping needs a left and right side");
expect(reasons).toContain("17:unsupported command: Plug");
});
it("recognises :action and <Action>() right-hand sides", () => {
expect(exOfRhs("<Action>(GotoDeclaration)")).toBe("action GotoDeclaration");
expect(exOfRhs(":action ReformatCode<CR>")).toBe("action ReformatCode");
expect(exOfRhs(":<C-u>action Back<cr>")).toBe("action Back");
expect(exOfRhs("dd")).toBeNull();
expect(exOfRhs(":action A<CR>j")).toBeNull();
});
it("parses a leader written as \\<Space> and a comma", () => {
const space = parseVimrc('let mapleader="\\<Space>"\nnmap <Leader>a b');
expect(space.commands).toEqual([{ kind: "map", mode: "normal", noremap: false, lhs: "<Space>a", rhs: "b" }]);
const def = parseVimrc("nmap <leader>a b");
expect(def.commands[0]).toMatchObject({ lhs: "\\a" });
});
});
describe(":action ids", () => {
it("maps IdeaVim ids to OpenIDEA ids and passes our own ids through", () => {
expect(resolveActionId("GotoDeclaration")).toBe("navigate.declaration");
expect(resolveActionId("ReformatCode")).toBe("code.reformat");
expect(resolveActionId("FindInPath")).toBe("find.inPath");
expect(resolveActionId("code.commentLine")).toBe("code.commentLine");
});
it("targets actions that exist", () => {
const known = new Set([
...Object.values(PRESETS).flatMap((k) => Object.keys(k)),
"editor.closeAllTabs",
"editor.unsplit",
"editor.splitDown",
"vcs.update",
]);
const missing = Object.entries(IDEA_ACTIONS).filter(([, id]) => !known.has(id));
expect(missing).toEqual([]);
});
});
+140
Переглянути файл
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { getCM, Vim } from "@replit/codemirror-vim";
import { embeddedEditorExtensions, useEditorRegistry } from "@/editor/api";
import { executeAction, registerAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { _handleEx, applyRcCommands, vimExtension } from "../engine";
import { activate } from "../index";
import { useVimMode } from "../mode";
import { parseVimrc } from "../rc";
import { updateVimSettings, useVimSettings, VIM_SETTINGS_KEY } from "../settings";
const tick = () => new Promise((r) => setTimeout(r, 0));
const until = async (cond: () => boolean) => {
for (let i = 0; i < 50 && !cond(); i++) await tick();
expect(cond()).toBe(true);
};
function makeView(extra = vimExtension()) {
const parent = document.createElement("div");
document.body.appendChild(parent);
return new EditorView({ parent, state: EditorState.create({ doc: "one two\nthree", extensions: extra }) });
}
describe("Vim mode toggle", () => {
let dispose = () => {};
beforeEach(() => {
localStorage.clear();
useVimSettings.setState({ enabled: false, handlers: {} }, true);
dispose = activate();
});
afterEach(() => dispose());
const hasContribution = () => useEditorRegistry.getState().contributions.has("vim");
it("vim.toggle turns Vim on and off for every editor, live, and persists it", async () => {
const diffSide = makeView(embeddedEditorExtensions("/p/a.ts"));
expect(hasContribution()).toBe(false);
await executeAction("vim.toggle");
expect(useVimSettings.getState().enabled).toBe(true);
expect(JSON.parse(localStorage.getItem(VIM_SETTINGS_KEY)!).enabled).toBe(true);
await until(hasContribution);
expect(getContext().vimMode).toBe(true);
expect(diffSide.dom.hasAttribute("data-openidea-vim")).toBe(true);
expect(getCM(diffSide)).not.toBeNull();
await executeAction("vim.toggle");
expect(useVimSettings.getState().enabled).toBe(false);
expect(hasContribution()).toBe(false);
expect(diffSide.dom.hasAttribute("data-openidea-vim")).toBe(false);
expect(getCM(diffSide)).toBeNull();
diffSide.destroy();
});
it("follows the setting changed from another window", async () => {
updateVimSettings({ enabled: true });
await until(hasContribution);
localStorage.setItem(VIM_SETTINGS_KEY, JSON.stringify({ enabled: false, handlers: {} }));
window.dispatchEvent(new StorageEvent("storage", { key: VIM_SETTINGS_KEY }));
expect(useVimSettings.getState().enabled).toBe(false);
expect(hasContribution()).toBe(false);
});
});
describe("Vim engine", () => {
const ran: string[] = [];
let disposers: Array<() => void> = [];
beforeEach(() => {
ran.length = 0;
disposers = ["navigate.declaration", "code.reformat", "file.save", "editor.closeTab"].map((id) =>
registerAction({ id, title: id, run: () => void ran.push(id) }),
);
});
afterEach(() => {
disposers.forEach((d) => d());
Vim.mapclear();
});
it(":action runs OpenIDEA actions by IdeaVim or OpenIDEA id", async () => {
const view = makeView();
_handleEx(view, "action GotoDeclaration");
_handleEx(view, "action code.reformat");
_handleEx(view, "action NoSuchAction");
await tick();
expect(ran).toEqual(["navigate.declaration", "code.reformat"]);
view.destroy();
});
it(":w saves, :q closes the tab, :wq does both", async () => {
const view = makeView();
_handleEx(view, "w");
await tick();
expect(ran).toEqual(["file.save"]);
_handleEx(view, "q");
await tick();
expect(ran).toEqual(["file.save", "editor.closeTab"]);
ran.length = 0;
_handleEx(view, "wq");
await until(() => ran.length === 2);
expect(ran).toEqual(["file.save", "editor.closeTab"]);
view.destroy();
});
it("applies rc mappings, including <Action>() ones", async () => {
const view = makeView();
const cm = getCM(view)!;
applyRcCommands(parseVimrc("nmap gd <Action>(GotoDeclaration)\nnnoremap H ^").commands);
Vim.handleKey(cm as never, "g", "user");
Vim.handleKey(cm as never, "d", "user");
await tick();
expect(ran).toEqual(["navigate.declaration"]);
view.destroy();
});
it("publishes the mode and lets the editor's Escape bindings (popups) go first", () => {
let popupOpen = true;
const closePopup = Prec.highest(
keymap.of([{ key: "Escape", run: () => (popupOpen ? !(popupOpen = false) : false) }]),
);
const view = makeView([vimExtension(), closePopup]);
view.focus();
const cm = getCM(view)!;
Vim.handleKey(cm as never, "i", "user");
expect(cm.state.vim?.insertMode).toBe(true);
const escape = () =>
view.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }),
);
escape();
expect(popupOpen).toBe(false);
expect(cm.state.vim?.insertMode).toBe(true);
escape();
expect(cm.state.vim?.insertMode).toBe(false);
Vim.handleKey(cm as never, "v", "user");
expect(useVimMode.getState().mode).toBe("visual");
view.destroy();
});
});
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* Shortcut conflicts between Vim and the IDE keymap (IdeaVim's Settings ›
* Editor › Vim table). Each Vim key has a handler: `vim` hands the keystroke
* to the editor even when an IDE action is bound to it; `ide` lets the IDE
* keymap run first (Vim still gets the key when no IDE action is enabled).
*/
import { normalizeBinding, type KeyClaim } from "@/platform/keymap";
import type { Handler, VimSettings } from "./settings";
export interface VimKey {
/** Canonical stroke (`Ctrl+R`). */
stroke: string;
/** What Vim does with it. */
vim: string;
/** IdeaVim's default handler. */
handler: Handler;
}
export const VIM_KEYS: readonly VimKey[] = [
{ stroke: "Escape", vim: "Exit to Normal mode", handler: "ide" },
{ stroke: "Ctrl+[", vim: "Exit to Normal mode", handler: "vim" },
{ stroke: "Ctrl+A", vim: "Increment number", handler: "vim" },
{ stroke: "Ctrl+B", vim: "Page up", handler: "vim" },
{ stroke: "Ctrl+C", vim: "Exit to Normal mode", handler: "ide" },
{ stroke: "Ctrl+D", vim: "Scroll half page down / unindent", handler: "vim" },
{ stroke: "Ctrl+E", vim: "Scroll line down", handler: "vim" },
{ stroke: "Ctrl+F", vim: "Page down", handler: "vim" },
{ stroke: "Ctrl+I", vim: "Jump forward", handler: "vim" },
{ stroke: "Ctrl+N", vim: "Next line", handler: "ide" },
{ stroke: "Ctrl+O", vim: "Jump back / one Normal command", handler: "vim" },
{ stroke: "Ctrl+P", vim: "Previous line", handler: "ide" },
{ stroke: "Ctrl+Q", vim: "Visual block", handler: "ide" },
{ stroke: "Ctrl+R", vim: "Redo / insert register", handler: "vim" },
{ stroke: "Ctrl+T", vim: "Indent", handler: "ide" },
{ stroke: "Ctrl+U", vim: "Scroll half page up / delete to line start", handler: "vim" },
{ stroke: "Ctrl+V", vim: "Visual block", handler: "vim" },
{ stroke: "Ctrl+W", vim: "Delete word before caret", handler: "vim" },
{ stroke: "Ctrl+X", vim: "Decrement number", handler: "vim" },
{ stroke: "Ctrl+Y", vim: "Scroll line up", handler: "vim" },
];
const byStroke = new Map(VIM_KEYS.map((k) => [normalizeBinding(k.stroke), k]));
/** The handler of `stroke`, or null when Vim does not use it. */
export function handlerFor(stroke: string, handlers: VimSettings["handlers"]): Handler | null {
const key = byStroke.get(normalizeBinding(stroke));
if (!key) return null;
return handlers[key.stroke] ?? key.handler;
}
export interface ConflictRow extends VimKey {
/** IDE actions bound to the stroke (first stroke of a binding included). */
actions: string[];
/** Effective handler. */
current: Handler;
}
/** The conflicts table: every Vim key with the IDE actions bound to it. */
export function conflictRows(
bindings: ReadonlyArray<readonly [string, string]>,
handlers: VimSettings["handlers"],
): ConflictRow[] {
return VIM_KEYS.map((k) => {
const stroke = normalizeBinding(k.stroke);
const actions = [
...new Set(bindings.filter(([b]) => b === stroke || b.startsWith(stroke + " ")).map(([, id]) => id)),
];
return { ...k, actions, current: handlers[k.stroke] ?? k.handler };
});
}
/** Marks the `.cm-editor` of an editor with Vim on. */
export const VIM_EDITOR_ATTR = "data-openidea-vim";
function inVimEditor(target: EventTarget | null): boolean {
const el = target as Element | null;
return !!el?.closest?.(`[${VIM_EDITOR_ATTR}]`);
}
/** Dispatcher claim: keystrokes whose handler is Vim go to a focused Vim editor. */
export function vimKeyClaim(settings: () => VimSettings): KeyClaim {
return (e, stroke) => {
const s = settings();
return s.enabled && inVimEditor(e.target) && handlerFor(stroke, s.handlers) === "vim";
};
}
+150
Переглянути файл
@@ -0,0 +1,150 @@
/**
* The Vim emulation itself (@replit/codemirror-vim), loaded only once Vim mode
* is turned on: the per-editor extension, OpenIDEA's Ex commands (`:w`, `:q`,
* `:action`) and applying the rc file.
*/
import { Prec, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, runScopeHandlers } from "@codemirror/view";
import { Vim, getCM, vim } from "@replit/codemirror-vim";
import { executeAction, getAction } from "@/platform/actions";
import { showStatusInfo } from "@/workbench/contributions";
import { VIM_EDITOR_ATTR } from "./conflicts";
import { useVimMode, type VimModeState } from "./mode";
import { resolveActionId, type RcCommand } from "./rc";
/**
* IdeaVim's Escape: an open popup, lookup, find bar or template closes first
* (the editor's Escape key bindings); Vim only gets Escape that nothing else
* wanted. In Visual mode Escape always leaves Visual mode.
*/
const escapeClosesPopupsFirst = EditorView.domEventHandlers({
keydown(e, view) {
if (e.key !== "Escape" || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
if (getCM(view)?.state.vim?.visualMode) return false;
return runScopeHandlers(view, e, "editor");
},
});
const modeTracker = ViewPlugin.define((view) => {
const cm = getCM(view);
let state: VimModeState = { mode: cm?.state.vim?.insertMode ? "insert" : "normal", sub: null };
const publish = () => useVimMode.setState(state);
// Not `view.hasFocus`: that also needs the window focused, and this editor stays the last focused one.
const focused = () => view.root.activeElement === view.contentDOM;
const onModeChange = (e: { mode: string; subMode?: string }) => {
state = { mode: e.mode, sub: e.subMode === "linewise" || e.subMode === "blockwise" ? e.subMode : null };
if (focused()) publish();
};
cm?.on("vim-mode-change", onModeChange);
view.contentDOM.addEventListener("focus", publish);
if (focused()) publish();
return {
destroy() {
cm?.off("vim-mode-change", onModeChange);
view.contentDOM.removeEventListener("focus", publish);
},
};
});
const caret = "var(--caret, var(--fg-strong))";
/** Block caret and command line in theme tokens (the package's defaults are fixed colours). */
const vimTheme = EditorView.theme({
"& .cm-vimCursorLayer .cm-fat-cursor": {
background: `color-mix(in srgb, ${caret} 60%, transparent)`,
},
"&:not(.cm-focused) .cm-vimCursorLayer .cm-fat-cursor": {
background: "none",
outline: `1px solid ${caret}`,
},
".cm-vim-panel": {
padding: "2px 10px",
minHeight: "1.6em",
alignItems: "center",
fontFamily: "var(--font-mono)",
fontSize: "var(--fs-code)",
background: "var(--bg-elev)",
color: "var(--fg)",
borderTop: "1px solid var(--border)",
},
".cm-vim-panel input": { color: "var(--fg)", fontFamily: "inherit", fontSize: "inherit" },
});
/** Vim for one editor. Highest precedence: Vim sees keys before the editor's keymaps. */
export function vimExtension(): Extension {
defineExCommands();
return [
Prec.highest([escapeClosesPopupsFirst, vim(), modeTracker]),
vimTheme,
EditorView.editorAttributes.of({ [VIM_EDITOR_ATTR]: "true" }),
];
}
function later(run: () => void) {
// Ex commands run inside Vim's key handling; closing the editor there would destroy it mid-call.
setTimeout(run, 0);
}
/** `:action <id>`: an IdeaVim action id or an OpenIDEA one. Returns the action run, or null. */
export function runIdeAction(id: string | undefined): string | null {
if (!id) {
showStatusInfo("Usage: :action <action id>");
return null;
}
const resolved = resolveActionId(id);
if (!getAction(resolved)) {
showStatusInfo(`Vim: no action "${id}"`);
return null;
}
later(() => void executeAction(resolved));
return resolved;
}
let exDefined = false;
/** OpenIDEA's Ex commands: `:w`, `:wa`, `:q`, `:qa`, `:wq`, `:x`, `:action`. */
export function defineExCommands() {
if (exDefined) return;
exDefined = true;
const run = (id: string) => () => later(() => void executeAction(id));
const saveThenClose = () =>
later(() => {
void executeAction("file.save").then(() => executeAction("editor.closeTab"));
});
Vim.defineEx("write", "w", run("file.save"));
Vim.defineEx("wall", "wa", run("file.save"));
Vim.defineEx("quit", "q", run("editor.closeTab"));
Vim.defineEx("qall", "qa", run("editor.closeAllTabs"));
Vim.defineEx("wq", "wq", saveThenClose);
Vim.defineEx("xit", "x", saveThenClose);
Vim.defineEx("action", "action", (_cm, params) => void runIdeAction(params.args?.[0]));
}
/** Applies rc commands (replacing earlier rc mappings). Returns problems to report. */
export function applyRcCommands(commands: readonly RcCommand[]): string[] {
defineExCommands();
Vim.mapclear();
const problems: string[] = [];
for (const c of commands) {
try {
if (c.kind === "set") {
const err = Vim.setOption(c.name, c.value);
if (err) problems.push(`set ${c.name}: ${err.message}`);
} else if (c.kind === "map") {
if (c.noremap) Vim.noremap(c.lhs, c.rhs, c.mode as string);
else Vim.map(c.lhs, c.rhs, c.mode as string);
} else {
Vim._mapCommand({ keys: c.lhs, type: "keyToEx", exArgs: { input: c.ex }, context: c.mode } as never);
}
} catch (e) {
problems.push(`${c.kind === "set" ? c.name : c.lhs}: ${e instanceof Error ? e.message : String(e)}`);
}
}
return problems;
}
/** Test hook: runs an Ex command line in `view`. */
export function _handleEx(view: EditorView, input: string) {
const cm = getCM(view);
if (cm) Vim.handleEx(cm as never, input);
}
+115
Переглянути файл
@@ -0,0 +1,115 @@
/**
* Vim mode, modelled on IntelliJ's IdeaVim: a persisted on/off toggle (Tools ›
* Vim, the status bar widget, Settings › Editor › Vim) applied live to every
* editor, shortcut conflicts the keymap dispatcher respects, and the
* `~/.ideavimrc` subset. The Vim engine loads only once Vim mode is on.
*/
import { executeAction, registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { registerKeyClaim } from "@/platform/keymap";
import { registerEditorContribution } from "@/editor/api";
import { registerSettingsPage, registerStatusItem, showStatusInfo } from "@/workbench/contributions";
import { lazyComponent } from "@/ui";
import { vimKeyClaim } from "./conflicts";
import { loadRcFile } from "./rcFile";
import { setVimEnabled, syncVimSettingsFromStorage, useVimSettings } from "./settings";
import { VimStatus } from "./VimStatus";
export const VIM_SETTINGS_PAGE = "editor.vim";
/** Settings › Editor › Vim (also used by the Settings window). */
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: VIM_SETTINGS_PAGE,
title: "Vim",
group: "Editor",
order: 45,
keywords: ["vim", "ideavim", "ideavimrc", "emulation", "shortcut conflicts", "handler"],
component: lazyComponent(() => import("./VimSettingsPage").then((m) => m.VimSettingsPage)),
});
}
type Engine = typeof import("./engine");
export function activate(): () => void {
let engine: Promise<Engine> | null = null;
let contribution: (() => void) | null = null;
let rcApplied = false;
let generation = 0;
const loadEngine = () => (engine ??= import("./engine"));
const reloadRc = async (quiet: boolean) => {
const [e, rc] = await Promise.all([loadEngine(), loadRcFile()]);
const problems = e.applyRcCommands(rc.commands);
rcApplied = true;
for (const p of problems) console.warn(`vimrc: ${p}`);
if (!quiet)
showStatusInfo(rc.commands.length ? "Vim: rc file reloaded" : "Vim: no ~/.openideavimrc or ~/.ideavimrc");
};
const apply = async (enabled: boolean) => {
const gen = ++generation;
setContext("vimMode", enabled);
if (!enabled) {
contribution?.();
contribution = null;
return;
}
const e = await loadEngine();
if (!rcApplied) await reloadRc(true).catch((err) => console.error("vimrc failed", err));
if (gen !== generation || contribution) return;
contribution = registerEditorContribution({
id: "vim",
order: 0,
embedded: true,
extensions: () => e.vimExtension(),
});
};
void apply(useVimSettings.getState().enabled);
const unsubscribe = useVimSettings.subscribe((s, prev) => {
if (s.enabled !== prev.enabled) void apply(s.enabled);
});
const disposers = [
unsubscribe,
syncVimSettingsFromStorage(),
registerKeyClaim(vimKeyClaim(() => useVimSettings.getState())),
activateSettingsPages(),
registerStatusItem({ id: "vim.mode", align: "right", priority: 45, component: VimStatus, title: "Vim" }),
registerActions([
{
id: "vim.toggle",
title: "Vim",
description: "Turn Vim emulation on or off in all editors",
category: "Tools",
keywords: ["vim", "ideavim", "emulator", "toggle vim mode"],
toggled: () => useVimSettings.getState().enabled,
run: () => setVimEnabled(!useVimSettings.getState().enabled),
},
{
id: "vim.reloadRc",
title: "Reload .ideavimrc",
category: "Tools",
keywords: ["vim", "vimrc", "openideavimrc"],
when: "vimMode",
run: () => reloadRc(false),
},
{
id: "vim.settings",
title: "Vim Settings…",
category: "Tools",
keywords: ["vim", "shortcut conflicts"],
run: () => void executeAction("file.settings", VIM_SETTINGS_PAGE),
},
]),
];
return () => {
generation++;
contribution?.();
contribution = null;
setContext("vimMode", false);
disposers.reverse().forEach((d) => d());
};
}
+17
Переглянути файл
@@ -0,0 +1,17 @@
/** The Vim mode of the focused (or last focused) Vim editor, for the status bar widget. */
import { create } from "zustand";
export interface VimModeState {
/** codemirror-vim's mode: `normal`, `insert`, `visual`, `replace`. */
mode: string;
/** Visual sub-mode. */
sub: "linewise" | "blockwise" | null;
}
export const useVimMode = create<VimModeState>(() => ({ mode: "normal", sub: null }));
/** IdeaVim's widget text: NORMAL, INSERT, VISUAL, V-LINE, V-BLOCK, REPLACE. */
export function modeLabel({ mode, sub }: VimModeState): string {
if (mode === "visual") return sub === "linewise" ? "V-LINE" : sub === "blockwise" ? "V-BLOCK" : "VISUAL";
return (mode || "normal").toUpperCase();
}
+226
Переглянути файл
@@ -0,0 +1,226 @@
/**
* `~/.openideavimrc` / `~/.ideavimrc`: the safe subset IdeaVim users keep in
* their rc file — `set` options, `map` variants, `let mapleader` — plus
* IdeaVim's `:action <id>` / `<Action>(id)` mappings, which run OpenIDEA
* actions. Everything else is reported as ignored, never executed.
*/
/** codemirror-vim mapping context; undefined = Normal, Visual and Operator-pending. */
export type MapMode = "normal" | "insert" | "visual" | "operatorPending" | undefined;
export type RcCommand =
| { kind: "set"; name: string; value: string | boolean }
| { kind: "map"; mode: MapMode; noremap: boolean; lhs: string; rhs: string }
/** A key mapped to one Ex command, e.g. `<Action>(ReformatCode)` → `action ReformatCode`. */
| { kind: "mapEx"; mode: MapMode; lhs: string; ex: string };
export interface RcIgnored {
line: number;
text: string;
reason: string;
}
export interface RcParseResult {
commands: RcCommand[];
ignored: RcIgnored[];
}
export const RC_FILES = [".openideavimrc", ".ideavimrc"] as const;
const MAP_RE = /^(n|v|x|i|o)?(map|noremap)$/;
const MAP_MODES: Record<string, MapMode> = {
"": undefined,
n: "normal",
v: "visual",
x: "visual",
i: "insert",
o: "operatorPending",
};
/** Options the Vim engine implements (others, e.g. IdeaVim's `ideajoin`, are reported as ignored). */
const SUPPORTED_OPTIONS = new Set(["textwidth", "tw", "pcre", "insertModeEscKeysTimeout", "langmap", "lmap"]);
const MAP_FLAGS = /^<(silent|unique|nowait|buffer|expr|script)>$/i;
function leaderKeys(value: string): string {
if (value === " " || /^\\?<space>$/i.test(value)) return "<Space>";
return value;
}
/** `let mapleader = ","` / `"\<Space>"` / `' '`: the quoted value, or null. */
function parseLetValue(rest: string): string | null {
const m = /^=\s*(["'])(.*)\1\s*$/.exec(rest.trim());
if (!m) return null;
return m[1] === '"' ? m[2]!.replace(/\\(<[^>]+>)/g, "$1").replace(/\\\\/g, "\\") : m[2]!;
}
function splitArgs(s: string): string[] {
return s.trim().split(/\s+/).filter(Boolean);
}
/** `<Action>(id)` or `:action id<CR>` (also `:<C-u>action`) as an Ex command line. */
export function exOfRhs(rhs: string): string | null {
const action = /^<Action>\(([^)\s]+)\)$/i.exec(rhs);
if (action) return `action ${action[1]}`;
const ex = /^:(?:<C-u>)?([^<]+)<CR>$/i.exec(rhs);
return ex ? ex[1]!.trim() : null;
}
export function parseVimrc(text: string): RcParseResult {
const commands: RcCommand[] = [];
const ignored: RcIgnored[] = [];
let leader = "\\";
const lines = text.split(/\r?\n/);
lines.forEach((raw, i) => {
const line = raw.trim();
if (!line || line.startsWith('"')) return;
const ignore = (reason: string) => ignored.push({ line: i + 1, text: line, reason });
const [word = "", ...restWords] = splitArgs(line);
const rest = line.slice(line.indexOf(word) + word.length);
if (word === "set" || word === "se") {
if (!restWords.length) return ignore("set without options");
for (const opt of restWords) {
const eq = /^([a-zA-Z]+)=(.*)$/.exec(opt);
const cmd: RcCommand | null = eq
? { kind: "set", name: eq[1]!, value: eq[2]! }
: /^[a-zA-Z]+$/.test(opt)
? !SUPPORTED_OPTIONS.has(opt) && opt.startsWith("no") && SUPPORTED_OPTIONS.has(opt.slice(2))
? { kind: "set", name: opt.slice(2), value: false }
: { kind: "set", name: opt, value: true }
: null;
if (!cmd) ignore(`unsupported option syntax: ${opt}`);
else if (!SUPPORTED_OPTIONS.has(cmd.name)) ignore(`option not supported: ${cmd.name}`);
else commands.push(cmd);
}
return;
}
if (word === "let") {
const m = /^let\s+(g:)?mapleader\s*(=.*)$/.exec(line);
const value = m ? parseLetValue(m[2]!) : null;
if (value === null || value === "") return ignore("only `let mapleader` is supported");
leader = leaderKeys(value);
return;
}
const map = MAP_RE.exec(word);
if (map) {
const args = splitArgs(rest).filter((a) => !MAP_FLAGS.test(a));
if (args.length < 2) return ignore("mapping needs a left and right side");
const withLeader = (keys: string) => keys.replace(/<leader>/gi, leader);
const lhs = withLeader(args[0]!);
const rhs = withLeader(args.slice(1).join(" "));
const mode = MAP_MODES[map[1] ?? ""];
const ex = exOfRhs(rhs);
if (ex) commands.push({ kind: "mapEx", mode, lhs, ex });
else commands.push({ kind: "map", mode, noremap: map[2] !== "map", lhs, rhs });
return;
}
ignore(`unsupported command: ${word}`);
});
return { commands, ignored };
}
/**
* IdeaVim (IntelliJ) action ids → OpenIDEA action ids, for `:action` and
* `<Action>()`. Our own ids (`code.reformat`) work as well.
*/
export const IDEA_ACTIONS: Readonly<Record<string, string>> = {
GotoDeclaration: "navigate.declaration",
GotoImplementation: "navigate.implementation",
GotoTypeDeclaration: "navigate.typeDeclaration",
GotoSuperMethod: "navigate.superMethod",
GotoTest: "navigate.gotoTest",
GotoClass: "navigate.gotoClass",
GotoFile: "navigate.gotoFile",
GotoSymbol: "navigate.gotoSymbol",
GotoAction: "navigate.findAction",
GotoLine: "navigate.gotoLine",
SearchEverywhere: "navigate.searchEverywhere",
RecentFiles: "navigate.recentFiles",
RecentLocations: "navigate.recentLocations",
Back: "navigate.back",
Forward: "navigate.forward",
FileStructurePopup: "navigate.fileStructure",
GotoNextError: "navigate.nextError",
GotoPreviousError: "navigate.prevError",
MethodDown: "navigate.nextMethod",
MethodUp: "navigate.previousMethod",
CallHierarchy: "navigate.callHierarchy",
TypeHierarchy: "navigate.typeHierarchy",
ToggleBookmark: "navigate.toggleBookmark",
ShowBookmarks: "navigate.showBookmarks",
SelectIn: "navigate.selectIn",
Find: "find.inFile",
Replace: "find.replaceInFile",
FindInPath: "find.inPath",
ReplaceInPath: "find.replaceInPath",
FindUsages: "find.usages",
ShowUsages: "find.showUsages",
HighlightUsagesInFile: "find.highlightUsages",
ReformatCode: "code.reformat",
OptimizeImports: "code.optimizeImports",
CommentByLineComment: "code.commentLine",
CommentByBlockComment: "code.commentBlock",
ShowIntentionActions: "code.showIntentions",
QuickJavaDoc: "code.quickDocumentation",
QuickImplementations: "code.quickDefinition",
ParameterInfo: "code.parameterInfo",
CodeCompletion: "code.completion",
Generate: "code.generate",
SurroundWith: "code.surroundWith",
EditorSelectWord: "code.extendSelection",
EditorUnSelectWord: "code.shrinkSelection",
CollapseRegion: "code.fold",
ExpandRegion: "code.unfold",
CollapseAllRegions: "code.foldAll",
ExpandAllRegions: "code.unfoldAll",
RenameElement: "refactor.rename",
"Refactorings.QuickListPopupAction": "refactor.thisMenu",
ChangeSignature: "refactor.changeSignature",
ExtractMethod: "refactor.extractMethod",
IntroduceVariable: "refactor.extractVariable",
IntroduceConstant: "refactor.extractConstant",
IntroduceField: "refactor.extractField",
IntroduceParameter: "refactor.extractParameter",
Inline: "refactor.inline",
Move: "refactor.move",
SafeDelete: "refactor.safeDelete",
Run: "run.run",
Debug: "run.debug",
RunClass: "run.runContext",
DebugClass: "run.debugContext",
Stop: "run.stop",
Rerun: "run.rerun",
ChooseRunConfiguration: "run.chooseConfig",
ToggleLineBreakpoint: "debug.toggleBreakpoint",
SaveAll: "file.save",
ShowSettings: "file.settings",
NewScratchFile: "file.newScratchFile",
CloseContent: "editor.closeTab",
CloseEditor: "editor.closeTab",
CloseAllEditors: "editor.closeAllTabs",
ReopenClosedTab: "editor.reopenClosedTab",
NextTab: "editor.nextTab",
PreviousTab: "editor.prevTab",
SplitVertically: "editor.splitRight",
SplitHorizontally: "editor.splitDown",
Unsplit: "editor.unsplit",
ToggleDistractionFreeMode: "editor.toggleZen",
ActivateProjectToolWindow: "toolwindow.project",
HideAllWindows: "toolwindow.hideAll",
"Vcs.QuickListPopupAction": "vcs.operations",
CheckinProject: "vcs.commit",
"Vcs.Push": "vcs.push",
"Vcs.UpdateProject": "vcs.update",
"Git.Branches": "vcs.branches",
Annotate: "vcs.annotate",
VcsShowNextChangeMarker: "vcs.nextChange",
VcsShowPrevChangeMarker: "vcs.prevChange",
"Compare.SameVersion": "vcs.showDiff",
};
/** The OpenIDEA action for an `:action` argument: an IdeaVim id, or one of ours as is. */
export function resolveActionId(id: string): string {
return IDEA_ACTIONS[id] ?? id;
}
+47
Переглянути файл
@@ -0,0 +1,47 @@
/** Locating and reading the rc file: `~/.openideavimrc`, else `~/.ideavimrc`. */
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { parseVimrc, RC_FILES, type RcIgnored, type RcParseResult } from "./rc";
export interface RcFile {
path: string;
text: string;
}
export async function readRcFile(): Promise<RcFile | null> {
if (!isTauri()) return null;
const { homeDir, join } = await import("@tauri-apps/api/path");
const home = await homeDir();
for (const name of RC_FILES) {
const path = await join(home, name);
try {
return { path, text: await call(commands.fsReadText(path)) };
} catch {
// Not there (or unreadable): try the next name.
}
}
return null;
}
export interface RcStatus {
/** The file read, or null when there is none. */
path: string | null;
mappings: number;
ignored: RcIgnored[];
loaded: boolean;
}
export const useVimRc = create<RcStatus>(() => ({ path: null, mappings: 0, ignored: [], loaded: false }));
/** Reads and parses the rc file, publishing what was found. */
export async function loadRcFile(): Promise<RcParseResult> {
const file = await readRcFile().catch(() => null);
const result = parseVimrc(file?.text ?? "");
useVimRc.setState({
path: file?.path ?? null,
mappings: result.commands.filter((c) => c.kind !== "set").length,
ignored: result.ignored,
loaded: true,
});
return result;
}
+64
Переглянути файл
@@ -0,0 +1,64 @@
/**
* Vim mode settings (Settings › Editor › Vim): on/off and the handler of each
* shortcut conflict. IDE-level, kept in localStorage and picked up by every
* window through `storage` events, like IdeaVim's application-level settings.
*/
import { create } from "zustand";
export type Handler = "vim" | "ide";
export interface VimSettings {
enabled: boolean;
/** Per-keystroke handler overrides (`Ctrl+R` → `ide`); missing keys use the default. */
handlers: Record<string, Handler>;
}
export const VIM_SETTINGS_KEY = "openidea.vim.v1";
export const DEFAULT_VIM_SETTINGS: VimSettings = { enabled: false, handlers: {} };
/** Settings as stored, validated: unknown values fall back to defaults. */
export function parseVimSettings(raw: string | null | undefined): VimSettings {
if (!raw) return DEFAULT_VIM_SETTINGS;
try {
const v = JSON.parse(raw) as Partial<VimSettings> | null;
const handlers: Record<string, Handler> = {};
if (v?.handlers && typeof v.handlers === "object")
for (const [k, h] of Object.entries(v.handlers)) if (h === "vim" || h === "ide") handlers[k] = h;
return { enabled: v?.enabled === true, handlers };
} catch {
return DEFAULT_VIM_SETTINGS;
}
}
function load(): VimSettings {
try {
return parseVimSettings(globalThis.localStorage?.getItem(VIM_SETTINGS_KEY));
} catch {
return DEFAULT_VIM_SETTINGS;
}
}
export const useVimSettings = create<VimSettings>(() => load());
export function updateVimSettings(patch: Partial<VimSettings>) {
useVimSettings.setState(patch);
try {
globalThis.localStorage?.setItem(VIM_SETTINGS_KEY, JSON.stringify(useVimSettings.getState()));
} catch {
// Storage unavailable: the setting lasts for this session.
}
}
export function setVimEnabled(enabled: boolean) {
if (useVimSettings.getState().enabled !== enabled) updateVimSettings({ enabled });
}
/** Picks up changes saved by another window (the Settings window). */
export function syncVimSettingsFromStorage(): () => void {
const onStorage = (e: StorageEvent) => {
if (e.key === VIM_SETTINGS_KEY) useVimSettings.setState(load(), true);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}
+26
Переглянути файл
@@ -119,6 +119,31 @@ export function applyKeymapSettings(preset: unknown, overrides: unknown, customK
let suspended = 0;
/**
* Decides whether a keystroke belongs to the focused component rather than to
* the IDE keymap (e.g. Vim mode's shortcut conflicts set to "Vim"). `stroke`
* is the canonical first stroke (`Ctrl+R`).
*/
export type KeyClaim = (e: KeyboardEvent, stroke: string) => boolean;
const keyClaims = new Set<KeyClaim>();
/** Registers a key claim; the dispatcher leaves claimed keystrokes to their target. */
export function registerKeyClaim(claim: KeyClaim): () => void {
keyClaims.add(claim);
return () => void keyClaims.delete(claim);
}
function isClaimed(e: KeyboardEvent, stroke: string): boolean {
for (const claim of keyClaims)
try {
if (claim(e, stroke)) return true;
} catch (err) {
console.error("key claim failed", err);
}
return false;
}
/**
* Pauses the global keyboard dispatcher (e.g. while recording a shortcut).
* Returns a function that resumes it; calls nest.
@@ -388,6 +413,7 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu
if (isAltGrTyping(e)) return;
const hasCommandMod = e.metaKey || e.ctrlKey || e.altKey;
if (!hasCommandMod && isPlainTextField(e.target) && !/^(Shift\+)?(F\d{1,2}|Escape)$/.test(stroke)) return;
if (isClaimed(e, stroke)) return;
}
const seq = [...pending, stroke].join(" ");
+1
Переглянути файл
@@ -287,6 +287,7 @@
"menu.db.showToolWindow": "Database",
"menu.db.newDataSource": "New Data Source…",
"menu.db.openConsole": "Open Query Console",
"menu.vim.toggle": "Vim",
"menu.window.new": "New Window",
"menu.window.next": "Next Project Window",
"menu.window.previous": "Previous Project Window",
+32
Переглянути файл
@@ -3259,6 +3259,38 @@
"inserts a template from the Includes or Code tab.": "插入“包含”或“代码”标签页中的模板。",
"File templates insert this with {code}, indented like the directive; a directive line whose include is empty is dropped. The file template variables apply.": "文件模板通过 {code} 插入此内容,缩进与该指令一致;所含内容为空的指令行将被删除。文件模板变量同样适用。",
"Inserted where a file template says {code}.": "插入到文件模板中写有 {code} 的位置。",
"Enable Vim emulation": "启用 Vim 模拟",
"Configuration file": "配置文件",
"mappings": "个映射",
"No ~/.openideavimrc or ~/.ideavimrc found.": "未找到 ~/.openideavimrc 或 ~/.ideavimrc。",
"Supported: set options, map / nmap / imap / vmap / noremap variants, let mapleader, and :action / <Action>() mappings that run IDE actions. :w saves, :q closes the editor tab.": "支持:set 选项、map / nmap / imap / vmap / noremap 等映射、let mapleader,以及运行 IDE 操作的 :action / <Action>() 映射。:w 保存,:q 关闭编辑器标签页。",
"Shortcut conflicts": "快捷键冲突",
"Keys Vim also handles. With Vim as the handler the key goes to the editor while Vim mode is on and an editor has focus; with IDE, an enabled IDE action runs first.": "Vim 也会处理的按键。处理程序为 Vim 时,在 Vim 模式开启且编辑器获得焦点时按键交给编辑器;为 IDE 时,先运行可用的 IDE 操作。",
"Shortcut": "快捷键",
"IDE action": "IDE 操作",
"Handler": "处理程序",
"Reset to Defaults": "重置为默认值",
"Exit to Normal mode": "返回 Normal 模式",
"Increment number": "数字加一",
"Decrement number": "数字减一",
"Page up": "向上翻页",
"Page down": "向下翻页",
"Scroll half page down / unindent": "向下滚动半页 / 减少缩进",
"Scroll half page up / delete to line start": "向上滚动半页 / 删除至行首",
"Scroll line down": "向下滚动一行",
"Scroll line up": "向上滚动一行",
"Jump forward": "前进跳转",
"Jump back / one Normal command": "后退跳转 / 执行一条 Normal 命令",
"Next line": "下一行",
"Previous line": "上一行",
"Visual block": "可视块",
"Redo / insert register": "重做 / 插入寄存器",
"Delete word before caret": "删除光标前的单词",
"Reload .ideavimrc": "重新加载 .ideavimrc",
"Vim Settings…": "Vim 设置…",
"Vim mode": "Vim 模式",
"Vim (disabled)": "Vim(已禁用)",
"Turn Vim emulation on or off in all editors": "在所有编辑器中开启或关闭 Vim 模拟",
"Remote JVM Debug": "远程 JVM 调试",
"Attach the debugger to a JVM started with a JDWP agent, here or on another machine": "将调试器附加到以 JDWP 代理启动的 JVM(本机或其他机器)",
"Debugger mode": "调试器模式",
+1
Переглянути файл
@@ -287,6 +287,7 @@
"menu.db.showToolWindow": "数据库",
"menu.db.newDataSource": "新建数据源…",
"menu.db.openConsole": "打开查询控制台",
"menu.vim.toggle": "Vim",
"menu.window.new": "新建窗口",
"menu.window.next": "下一个项目窗口",
"menu.window.previous": "上一个项目窗口",