diff --git a/package.json b/package.json index 3cc6f3431..efb1b051f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cedddec2d..06ab3552a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/commands@6.11.1)(@codemirror/language@6.13.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.6)(@codemirror/view@6.43.14) '@tanstack/react-virtual': specifier: ^3.14.21 version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -571,6 +574,18 @@ packages: engines: {node: '>=20'} hasBin: true + '@replit/codemirror-vim-core@0.1.0': + resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==} + + '@replit/codemirror-vim@6.4.0': + 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/binding-android-arm-eabi@1.2.14': 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/binding-linux-ppc64-gnu@1.2.14': resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.14': 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/binding-openharmony-arm64@1.2.14': resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} @@ -1302,18 +1320,21 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} @@ -2382,6 +2403,17 @@ snapshots: dependencies: playwright: 1.64.1 + '@replit/codemirror-vim-core@0.1.0': {} + + '@replit/codemirror-vim@6.4.0(@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)': + 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/binding-android-arm-eabi@1.2.14': optional: true diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index b3fdce8e5..6bc4aa344 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -630,6 +630,8 @@ pub fn spec(os: Os) -> Vec { 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", diff --git a/src/editor/api.ts b/src/editor/api.ts index 477396d78..060ec4acf 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -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(); + +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) => [...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; } diff --git a/src/features/index.ts b/src/features/index.ts index 603dff70b..bda1d8574 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -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) { diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 7da9a4202..5cf33d48d 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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; diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 3b6866b7c..ef5bdbf26 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -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; diff --git a/src/features/vim/Vim.module.css b/src/features/vim/Vim.module.css new file mode 100644 index 000000000..029fa62c5 --- /dev/null +++ b/src/features/vim/Vim.module.css @@ -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; +} diff --git a/src/features/vim/VimSettingsPage.tsx b/src/features/vim/VimSettingsPage.tsx new file mode 100644 index 000000000..fc00f8749 --- /dev/null +++ b/src/features/vim/VimSettingsPage.tsx @@ -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 ( +
+ updateVimSettings({ enabled })} + label="Enable Vim emulation" + /> + +
+
+ + Configuration file + + +
+

+ {rc.path ? ( + <> + {rc.path} · {rc.mappings} mappings + + ) : ( + No ~/.openideavimrc or ~/.ideavimrc found. + )} +

+

+ + 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. + +

+ {rc.ignored.length > 0 && ( +
    + {rc.ignored.map((i) => ( +
  • + + {i.line}: {i.text} + {" "} + — {i.reason} +
  • + ))} +
+ )} +
+ +
+
+ + 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. + +

+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
+ Shortcut + + IDE action + + Vim + + Handler +
{formatBinding(r.stroke)}{r.actions.length ? r.actions.map((id) => actions.get(id)?.title ?? id).join(", ") : "—"} + {r.vim} + +
+
+ +
+
+
+ ); +} diff --git a/src/features/vim/VimStatus.tsx b/src/features/vim/VimStatus.tsx new file mode 100644 index 000000000..6652710d6 --- /dev/null +++ b/src/features/vim/VimStatus.tsx @@ -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(null); + const label = enabled ? modeLabel(mode) : "Vim"; + return ( + <> + setOpen((v) => !v)} + > + {label} + + 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…" }, + ]} + /> + + ); +} diff --git a/src/features/vim/__tests__/conflicts.test.ts b/src/features/vim/__tests__/conflicts.test.ts new file mode 100644 index 000000000..983caafa1 --- /dev/null +++ b/src/features/vim/__tests__/conflicts.test.ts @@ -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 = `
`; + 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); + }); +}); diff --git a/src/features/vim/__tests__/rc.test.ts b/src/features/vim/__tests__/rc.test.ts new file mode 100644 index 000000000..f94d58362 --- /dev/null +++ b/src/features/vim/__tests__/rc.test.ts @@ -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 f :action FindInPath +nmap gd (GotoDeclaration) +map r (ReformatCode) +imap jk +vnoremap < c (code.commentLine) +let g:mapleader = "," +nmap w :w +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: "f", ex: "action FindInPath" }, + { kind: "mapEx", mode: "normal", lhs: "gd", ex: "action GotoDeclaration" }, + { kind: "mapEx", mode: undefined, lhs: "r", ex: "action ReformatCode" }, + { kind: "map", mode: "insert", noremap: false, lhs: "jk", rhs: "" }, + { kind: "map", mode: "visual", noremap: true, lhs: "<", rhs: "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 () right-hand sides", () => { + expect(exOfRhs("(GotoDeclaration)")).toBe("action GotoDeclaration"); + expect(exOfRhs(":action ReformatCode")).toBe("action ReformatCode"); + expect(exOfRhs(":action Back")).toBe("action Back"); + expect(exOfRhs("dd")).toBeNull(); + expect(exOfRhs(":action Aj")).toBeNull(); + }); + + it("parses a leader written as \\ and a comma", () => { + const space = parseVimrc('let mapleader="\\"\nnmap a b'); + expect(space.commands).toEqual([{ kind: "map", mode: "normal", noremap: false, lhs: "a", rhs: "b" }]); + const def = parseVimrc("nmap 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([]); + }); +}); diff --git a/src/features/vim/__tests__/vim.test.ts b/src/features/vim/__tests__/vim.test.ts new file mode 100644 index 000000000..592321df9 --- /dev/null +++ b/src/features/vim/__tests__/vim.test.ts @@ -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 () ones", async () => { + const view = makeView(); + const cm = getCM(view)!; + applyRcCommands(parseVimrc("nmap gd (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(); + }); +}); diff --git a/src/features/vim/conflicts.ts b/src/features/vim/conflicts.ts new file mode 100644 index 000000000..276c8ae15 --- /dev/null +++ b/src/features/vim/conflicts.ts @@ -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, + 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"; + }; +} diff --git a/src/features/vim/engine.ts b/src/features/vim/engine.ts new file mode 100644 index 000000000..12be24d6f --- /dev/null +++ b/src/features/vim/engine.ts @@ -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 `: 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 "); + 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); +} diff --git a/src/features/vim/index.ts b/src/features/vim/index.ts new file mode 100644 index 000000000..9fbcef2d4 --- /dev/null +++ b/src/features/vim/index.ts @@ -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 | 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()); + }; +} diff --git a/src/features/vim/mode.ts b/src/features/vim/mode.ts new file mode 100644 index 000000000..239091920 --- /dev/null +++ b/src/features/vim/mode.ts @@ -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(() => ({ 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(); +} diff --git a/src/features/vim/rc.ts b/src/features/vim/rc.ts new file mode 100644 index 000000000..96199afbf --- /dev/null +++ b/src/features/vim/rc.ts @@ -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)` 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. `(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 = { + "": 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 === " " || /^\\?$/i.test(value)) return ""; + return value; +} + +/** `let mapleader = ","` / `"\"` / `' '`: 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); +} + +/** `(id)` or `:action id` (also `:action`) as an Ex command line. */ +export function exOfRhs(rhs: string): string | null { + const action = /^\(([^)\s]+)\)$/i.exec(rhs); + if (action) return `action ${action[1]}`; + const ex = /^:(?:)?([^<]+)$/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(//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 + * `()`. Our own ids (`code.reformat`) work as well. + */ +export const IDEA_ACTIONS: Readonly> = { + 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; +} diff --git a/src/features/vim/rcFile.ts b/src/features/vim/rcFile.ts new file mode 100644 index 000000000..382bd4fce --- /dev/null +++ b/src/features/vim/rcFile.ts @@ -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 { + 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(() => ({ path: null, mappings: 0, ignored: [], loaded: false })); + +/** Reads and parses the rc file, publishing what was found. */ +export async function loadRcFile(): Promise { + 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; +} diff --git a/src/features/vim/settings.ts b/src/features/vim/settings.ts new file mode 100644 index 000000000..6b530a04b --- /dev/null +++ b/src/features/vim/settings.ts @@ -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; +} + +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 | null; + const handlers: Record = {}; + 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(() => load()); + +export function updateVimSettings(patch: Partial) { + 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); +} diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index a5aa7acbd..7a304ac9b 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -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(); + +/** 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(" "); diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 912da289a..fa87c8a9e 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b3dd9d601..8614018ff 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 / () mappings that run IDE actions. :w saves, :q closes the editor tab.": "支持:set 选项、map / nmap / imap / vmap / noremap 等映射、let mapleader,以及运行 IDE 操作的 :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": "调试器模式", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index b0bddcb6c..72665c73e 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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": "上一个项目窗口",