From feef7db39d6cff48f62d06cb5258f2de1a2dda2b Mon Sep 17 00:00:00 2001 From: arookieofc Date: Thu, 12 Nov 2026 10:24:35 +0800 Subject: [PATCH] feat(keymap): export the current keymap as JSON (#2852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Keymap's gear menu gets Export Keymap as JSON…: the selected keymap's name, the preset it is based on and every configurable action's resolved bindings, so the full mapping can be read or diffed outside the IDE. Written with the fs commands like the color scheme export; strings in the en/zh catalog. Part of #287. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- .../settings/__tests__/keymap.test.ts | 28 +++++++++- src/features/settings/keymap/KeymapPage.tsx | 52 +++++++++++++++++++ src/platform/locales/text/en.json | 7 ++- src/platform/locales/text/zh.json | 5 ++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/features/settings/__tests__/keymap.test.ts b/src/features/settings/__tests__/keymap.test.ts index 89d5ff6da..de4c8337f 100644 --- a/src/features/settings/__tests__/keymap.test.ts +++ b/src/features/settings/__tests__/keymap.test.ts @@ -36,7 +36,7 @@ import { withCustomOverrides, type KeymapDraft, } from "../keymap/model"; -import { buildKeymapTree, keymapSearchLabels } from "../keymap/KeymapPage"; +import { buildKeymapTree, keymapJson, keymapSearchLabels } from "../keymap/KeymapPage"; import { groupPath } from "../keymap/groups"; import { registerEditorActions } from "@/editor/core/actions"; import { registerLspActions } from "@/editor/lsp/actions"; @@ -59,6 +59,32 @@ it("finds the translated declaration action by 声明 and its English title", as } }); +describe("keymapJson", () => { + it("exports the keymap's name, base preset and every bound action", () => { + registerEditorActions(); + const out = JSON.parse( + keymapJson("My Keymap", "intellij-macos", allActions(), { preset: "intellij-macos", overrides: {} }), + ); + expect(out.name).toBe("My Keymap"); + expect(out.basedOn).toBe("intellij-macos"); + expect(out.actions["code.duplicateLine"]).toEqual(["Meta+D"]); + expect(out.actions["navigate.gotoLine"]).toEqual(["Meta+L"]); + // Unbound actions are omitted, not exported as empty lists. + expect(Object.values(out.actions).every((keys) => Array.isArray(keys) && keys.length > 0)).toBe(true); + }); + + it("exports overrides over the preset", () => { + registerEditorActions(); + const out = JSON.parse( + keymapJson("Custom", "intellij-macos", allActions(), { + preset: "intellij-macos", + overrides: { "code.duplicateLine": ["Ctrl+T"] }, + }), + ); + expect(out.actions["code.duplicateLine"]).toEqual(["Ctrl+T"]); + }); +}); + const key = ( code: string, mods: Partial> = {}, diff --git a/src/features/settings/keymap/KeymapPage.tsx b/src/features/settings/keymap/KeymapPage.tsx index 5f915c566..7c31ca179 100644 --- a/src/features/settings/keymap/KeymapPage.tsx +++ b/src/features/settings/keymap/KeymapPage.tsx @@ -11,6 +11,7 @@ import clsx from "clsx"; import { useActions, allActions, type Action } from "@/platform/actions"; import { defaultPreset, formatBinding, type Keymap, PRESET_TITLES, PRESETS, useKeymap } from "@/platform/keymap"; import { call, commands } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; import { ask, confirm, showError } from "@/workbench/messages"; import { HighlightedText, @@ -73,6 +74,21 @@ export function keymapSearchLabels(): string[] { const groupKey = (path: readonly string[]) => `grp:${path.join("/")}`; +/** + * The JSON written by "Export Keymap as JSON…": the keymap's name, the preset + * it is based on, and every configurable action's resolved bindings, so the + * mapping can be read or compared to IDEA offline. + */ +export function keymapJson(name: string, basedOn: string, actions: readonly Action[], draft: KeymapDraft): string { + const bindings: Keymap = {}; + for (const action of actions) { + if (!isKeymapAction(action, draft)) continue; + const keys = resolveBindings(draft, action.id); + if (keys.length) bindings[action.id] = keys; + } + return JSON.stringify({ name, basedOn, actions: bindings }, null, 2); +} + function sortGroup(g: GroupNode): void { const actions = g.children.filter((c) => c.kind === "action"); const groups = g.children.filter((c): c is GroupNode => c.kind === "group"); @@ -261,6 +277,41 @@ export function KeymapPage() { }); }; + const exportKeymap = async () => { + const { save } = await import("@/platform/dialog"); + const name = keymapName(selectedId, customs); + const path = await save({ + title: tx("Export Keymap"), + defaultPath: `${name.replace(/[/\\:*?"<>|]/g, "_")}.json`, + filters: [{ name: tx("OpenIDEA Keymap"), extensions: ["json"] }], + }); + if (!path) return; + const json = keymapJson( + name, + isCustom ? (customs[selectedId]?.parent ?? draft.preset) : selectedId, + allActions(), + draft, + ); + try { + // The save dialog already confirmed replacing an existing file. + await call(commands.fsDelete(path)).catch(() => {}); + await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(json)))); + notifications().notify({ + level: "info", + title: tx("Keymap exported"), + body: path, + key: "keymap.export", + }); + } catch (e) { + notifications().notify({ + level: "error", + title: tx("Cannot export keymap"), + body: e instanceof Error ? e.message : String(e), + key: "keymap.export", + }); + } + }; + const gearMenu = (): MenuItem[] => [ { label: "Duplicate…", @@ -268,6 +319,7 @@ export function KeymapPage() { run: () => setNameDialog({ mode: "duplicate", initial: copyName(keymapName(selectedId, customs), customs) }), }, { label: "Import IntelliJ IDEA Keymap…", run: () => void importIdeaKeymap() }, + { label: "Export Keymap as JSON…", run: () => void exportKeymap() }, { label: "Rename…", icon: "edit", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index ebd1aa4f7..6ab903147 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1331,5 +1331,10 @@ "Examples": "Examples", "Remove File": "Remove File", "Delegate IDE build/run actions to Maven": "Delegate IDE build/run actions to Maven", - "Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server." + "Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.", + "Export Keymap as JSON…": "Export Keymap as JSON…", + "Export Keymap": "Export Keymap", + "OpenIDEA Keymap": "OpenIDEA Keymap", + "Keymap exported": "Keymap exported", + "Cannot export keymap": "Cannot export keymap" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 376d11a58..fc3607e0e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7435,6 +7435,11 @@ "Not available in OpenIDEA ({length}): {join}.": "OpenIDEA 中不可用({length}):{join}。", "Skipped {skippedShortcuts} unsupported shortcuts.": "已跳过 {skippedShortcuts} 个不支持的快捷键。", "Import IntelliJ IDEA Keymap…": "导入 IntelliJ IDEA 键盘映射…", + "Export Keymap as JSON…": "将键盘映射导出为 JSON…", + "Export Keymap": "导出键盘映射", + "OpenIDEA Keymap": "OpenIDEA 键盘映射", + "Keymap exported": "键盘映射已导出", + "Cannot export keymap": "无法导出键盘映射", "Color picker": "颜色选择器", "HEX": "十六进制", "Alpha percent": "透明度百分比",