дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
cac95273d3
коміт
7146779f6c
24 files changed
+1458
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 /
|
||||
<Action>() 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>
|
||||
);
|
||||
}
|
||||
@@ -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…" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "调试器模式",
|
||||
|
||||
@@ -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": "上一个项目窗口",
|
||||
|
||||
Reference in new issue
Заблокувати користувача