feat(editor): support Vim config file

This commit is contained in:
TuZaaaaa
2026-09-29 22:25:59 +08:00
committed by GitHub
parent 4269a61e2c
commit dcaa0932f1
9 changed files with 347 additions and 7 deletions
@@ -11,6 +11,8 @@
// (delivery is same-tick), never a pending undelivered one.
let lastHandledFormatRequestId = 0;
let lastHandledCompressRequestId = 0;
let vimMappingsApplied = false;
let vimClipboardConfigured = false;
</script>
<script setup lang="ts">
@@ -59,7 +61,7 @@ import { parkEditorNativeSelection, type EditorNativeSelectionPark } from "@/lib
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import QueryEditorContextMenu, { type QueryEditorContextMenuState, type QueryEditorContextMenuActions } from "./QueryEditorContextMenu.vue";
import { clipboardLineEndings, readTextFromClipboard } from "@/lib/common/clipboard";
import { clipboardLineEndings, copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import { supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
@@ -107,6 +109,8 @@ import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { applyVimConfig, isVimMappingCommand, loadVimConfig } from "@/lib/editor/vimConfig";
import { configureVimSystemClipboard } from "@/lib/editor/vimSystemClipboard";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
@@ -1485,11 +1489,28 @@ function vimModeExtension(enabled = settingsStore.editorSettings.vimModeEnabled)
function configureDbxVimCommands(vimApi: typeof import("@replit/codemirror-vim").Vim) {
if (codeMirrorRuntime.dbxVimCommandsConfigured) return;
codeMirrorRuntime.dbxVimCommandsConfigured = true;
if (!vimClipboardConfigured) {
configureVimSystemClipboard(vimApi, { readText: readTextFromClipboard, writeText: copyToClipboard });
vimClipboardConfigured = true;
}
vimApi.defineEx("write", "w", (cm) => {
cm.cm6?.contentDOM.dispatchEvent(new CustomEvent(DBX_VIM_SAVE_EVENT, { bubbles: true }));
});
}
let vimConfigApplied = false;
function applyVimConfigToCurrentEditor(commands: readonly string[]) {
const vimApi = codeMirrorRuntime.codeMirrorVimApi;
if (vimConfigApplied || !view.value || !codeMirrorRuntime.codeMirrorGetVimCm || !vimApi) return;
const cm = codeMirrorRuntime.codeMirrorGetVimCm(view.value);
if (!cm) return;
const commandsForEditor = commands.filter((command) => !vimMappingsApplied || !isVimMappingCommand(command));
applyVimConfig(commandsForEditor, (command) => vimApi.handleEx(cm as Parameters<typeof vimApi.handleEx>[0], command));
vimMappingsApplied = true;
vimConfigApplied = true;
}
async function ensureCodeMirrorVim() {
if (codeMirrorRuntime.codeMirrorVim && codeMirrorRuntime.codeMirrorVimApi && codeMirrorRuntime.codeMirrorGetVimCm) return true;
codeMirrorRuntime.codeMirrorVimImportPromise ??= import("@replit/codemirror-vim");
@@ -1880,6 +1901,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
if (initialSettings.vimModeEnabled) {
await ensureCodeMirrorVim();
}
const vimCommands = initialSettings.vimModeEnabled ? await loadVimConfig() : [];
const { currentStatementFrameExtension, activeLineHighlighter } = sqlExtensions.createViewDecorations();
function updateLargeDocumentMode(currentView: EditorViewType) {
largeDocumentMode.value = shouldUseQueryEditorLargeDocumentModeForSize(currentView.state.doc.length, currentView.state.doc.lines);
@@ -2152,6 +2174,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
parent: editorElement,
onReady() {
if (!view.value) return;
if (initialSettings.vimModeEnabled) applyVimConfigToCurrentEditor(vimCommands);
syncQueryEditorInsertContext(view.value);
batchSelection.attach(view.value, tooltipParent);
postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM);
@@ -2468,7 +2491,7 @@ async function applyEditorAppearance() {
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
syncEditorDiagnosticCssVars();
const themeColors = getCurrentCustomThemeColors();
const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]);
const [themeExt, , vimCommands] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false), ss.vimModeEnabled ? loadVimConfig() : Promise.resolve([])]);
if (
!view.value ||
!codeMirrorRuntime.codeMirrorTheme ||
@@ -2494,6 +2517,8 @@ async function applyEditorAppearance() {
codeMirrorRuntime.runKeymapComp.reconfigure(runKeymapExtension(codeMirrorRuntime.editorViewModule.keymap)),
],
});
if (ss.vimModeEnabled) applyVimConfigToCurrentEditor(vimCommands);
else vimConfigApplied = false;
}
watch(
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { applyVimConfig, isVimMappingCommand, parseVimConfig } from "../vimConfig";
const fs = { exists: vi.fn(), readTextFile: vi.fn() };
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/api/path", () => ({
homeDir: () => Promise.resolve("/home/tester"),
join: (...parts: string[]) => Promise.resolve(parts.join("/")),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
exists: (path: string) => fs.exists(path),
readTextFile: (path: string) => fs.readTextFile(path),
}));
describe("Vim config", () => {
beforeEach(() => {
fs.exists.mockReset();
fs.readTextFile.mockReset();
});
it.each(["\n", "\r\n"])("parses commands, comments and blank lines with %j endings", (ending) => {
expect(parseVimConfig(['" DBX Vim config', "", " imap jj <Esc> ", " nmap Y y$", ' " another comment'].join(ending))).toEqual(["imap jj <Esc>", "nmap Y y$"]);
});
it("loads the file once and reuses its commands for later editors", async () => {
fs.exists.mockResolvedValue(true);
fs.readTextFile.mockResolvedValue("imap jj <Esc>\r\nnmap Y y$\r\n");
vi.resetModules();
const { loadVimConfig } = await import("../vimConfig");
const [first, second] = await Promise.all([loadVimConfig(), loadVimConfig()]);
expect(first).toEqual(["imap jj <Esc>", "nmap Y y$"]);
expect(second).toEqual(first);
expect(fs.exists).toHaveBeenCalledExactlyOnceWith("/home/tester/.dbx/vimrc");
expect(fs.readTextFile).toHaveBeenCalledExactlyOnceWith("/home/tester/.dbx/vimrc");
});
it("starts without a config file", async () => {
fs.exists.mockResolvedValue(false);
vi.resetModules();
const { loadVimConfig } = await import("../vimConfig");
expect(await loadVimConfig()).toEqual([]);
expect(fs.readTextFile).not.toHaveBeenCalled();
});
it("continues when the file cannot be read", async () => {
fs.exists.mockResolvedValue(true);
fs.readTextFile.mockRejectedValue(new Error("denied"));
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
vi.resetModules();
const { loadVimConfig } = await import("../vimConfig");
expect(await loadVimConfig()).toEqual([]);
expect(warning).toHaveBeenCalledOnce();
} finally {
warning.mockRestore();
}
});
it("continues after an invalid command", () => {
const execute = vi.fn((command: string) => {
if (command === "bogus") throw new Error("unknown command");
});
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
applyVimConfig(["imap jj <Esc>", "bogus", "nmap Y y$"], execute);
expect(execute.mock.calls.map(([command]) => command)).toEqual(["imap jj <Esc>", "bogus", "nmap Y y$"]);
expect(warning).toHaveBeenCalledOnce();
} finally {
warning.mockRestore();
}
});
it("applies an insert mapping to a CodeMirror Vim editor", async () => {
const [{ EditorState }, { EditorView }, { Vim, vim, getCM }] = await Promise.all([import("@codemirror/state"), import("@codemirror/view"), import("@replit/codemirror-vim")]);
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({ parent, state: EditorState.create({ doc: "select 1", extensions: [vim()] }) });
try {
const cm = getCM(view);
expect(cm).not.toBeNull();
if (!cm) return;
applyVimConfig(["imap jj <Esc>"], (command) => Vim.handleEx(cm, command));
Vim.handleKey(cm, "i");
expect(cm.state.vim?.insertMode).toBe(true);
Vim.handleKey(cm, "j");
Vim.handleKey(cm, "j");
expect(cm.state.vim?.insertMode).toBe(false);
const secondParent = document.createElement("div");
document.body.append(secondParent);
const secondView = new EditorView({ parent: secondParent, state: EditorState.create({ doc: "select 2", extensions: [vim()] }) });
try {
const secondCm = getCM(secondView);
expect(secondCm).not.toBeNull();
if (!secondCm) return;
Vim.handleKey(secondCm, "i");
Vim.handleKey(secondCm, "j");
Vim.handleKey(secondCm, "j");
expect(secondCm.state.vim?.insertMode).toBe(false);
} finally {
secondView.destroy();
secondParent.remove();
}
} finally {
view.destroy();
parent.remove();
}
});
it("identifies shared mappings so later editor tabs do not add duplicates", () => {
expect(isVimMappingCommand("imap jj <Esc>")).toBe(true);
expect(isVimMappingCommand("nmap Y y$")).toBe(true);
expect(isVimMappingCommand("inoremap jk <Esc>")).toBe(true);
expect(isVimMappingCommand("set ignorecase")).toBe(false);
});
});
@@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { configureVimSystemClipboard } from "../vimSystemClipboard";
describe("Vim system clipboard", () => {
it("connects unnamed yanks and pastes to the clipboard when configured", async () => {
const [{ EditorState }, { EditorView }, { Vim, vim, getCM }] = await Promise.all([import("@codemirror/state"), import("@codemirror/view"), import("@replit/codemirror-vim")]);
const clipboard = {
readText: vi.fn().mockResolvedValue("external"),
writeText: vi.fn().mockResolvedValue(undefined),
};
configureVimSystemClipboard(Vim, clipboard);
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({ parent, state: EditorState.create({ doc: "A", extensions: [vim()] }) });
try {
const cm = getCM(view);
expect(cm).not.toBeNull();
if (!cm) return;
const registers = Vim.getRegisterController();
registers.pushText(undefined, "yank", "internal");
expect(clipboard.writeText).not.toHaveBeenCalled();
Vim.handleEx(cm, "set clipboard=unnamedplus");
expect(Vim.getOption("clipboard", cm as Parameters<typeof Vim.getOption>[1])).toBe("unnamedplus");
registers.pushText(undefined, "yank", "selected SQL");
expect(clipboard.writeText).toHaveBeenCalledWith("selected SQL");
Vim.handleKey(cm, "y");
Vim.handleKey(cm, "y");
expect(clipboard.writeText).toHaveBeenCalledWith("A\n");
Vim.handleKey(cm, "p");
await vi.waitFor(() => expect(view.state.doc.toString()).toContain("external"));
expect(clipboard.readText).toHaveBeenCalledOnce();
Vim.handleEx(cm, "set clipboard=");
clipboard.writeText.mockClear();
registers.pushText(undefined, "yank", "local only");
expect(clipboard.writeText).not.toHaveBeenCalled();
} finally {
view.destroy();
parent.remove();
}
});
});
+46
View File
@@ -0,0 +1,46 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
/** Commands supported by CodeMirror Vim are read from the desktop user's file. */
export function parseVimConfig(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('"'));
}
// CodeMirror Vim stores mappings globally. Applying one on every tab would
// keep prepending duplicate mappings to its shared keymap.
export function isVimMappingCommand(command: string): boolean {
return /^(?:[niv]?noremap|[niv]?map|[niv]?unmap|[niv]?mapclear)(?:\s|$|!)/.test(command);
}
let loading: Promise<string[]> | null = null;
/** Read once per app session; a restart picks up edits to ~/.dbx/vimrc. */
export function loadVimConfig(): Promise<string[]> {
loading ??= (async () => {
if (!isTauriRuntime()) return [];
try {
const { homeDir, join } = await import("@tauri-apps/api/path");
const { exists, readTextFile } = await import("@tauri-apps/plugin-fs");
const file = await join(await homeDir(), ".dbx", "vimrc");
if (!(await exists(file))) return [];
return parseVimConfig(await readTextFile(file));
} catch (error) {
console.warn("[vim-config] could not read ~/.dbx/vimrc", error);
return [];
}
})();
return loading;
}
/** A bad command must not prevent the remaining commands or editor startup. */
export function applyVimConfig(commands: readonly string[], execute: (command: string) => void): void {
for (const command of commands) {
try {
execute(command);
} catch (error) {
console.warn(`[vim-config] ignored command: ${command}`, error);
}
}
}
@@ -0,0 +1,78 @@
type VimApi = typeof import("@replit/codemirror-vim").Vim;
export interface VimClipboardAccess {
readText(): Promise<string>;
writeText(text: string): Promise<void>;
}
/** Add `set clipboard=unnamedplus` without replacing CodeMirror Vim's editing commands. */
export function configureVimSystemClipboard(vimApi: VimApi, clipboard: VimClipboardAccess): void {
let clipboardOption = "";
let unnamedPlus = false;
vimApi.defineOption("clipboard", "", "string", ["cb"], (value) => {
if (value !== undefined) {
clipboardOption = value;
unnamedPlus = value.split(",").includes("unnamedplus");
}
return clipboardOption;
});
const registers = vimApi.getRegisterController();
const pushText = registers.pushText.bind(registers);
registers.pushText = (registerName, operator, text, linewise, blockwise) => {
if (unnamedPlus && registerName === "+") {
// The library writes explicit + yanks through navigator.clipboard. Use
// DBX's native clipboard path in the desktop webview instead.
const value = linewise && !text.endsWith("\n") ? `${text}\n` : text;
registers.getRegister("+").setText(value, linewise, blockwise);
registers.unnamedRegister.setText(value, linewise, blockwise);
} else {
pushText(registerName, operator, text, linewise, blockwise);
}
if (unnamedPlus && registerName !== "_") {
void clipboard.writeText(registers.unnamedRegister.toString()).catch((error) => {
console.warn("[vim-config] could not write to system clipboard", error);
});
}
};
const findKey = vimApi.findKey.bind(vimApi);
const pendingPastes = new WeakSet<object>();
vimApi.findKey = (cm, key, origin) => {
if (pendingPastes.has(cm)) return () => true;
const registerName = cm.state.vim?.inputState.registerName;
const command = findKey(cm, key, origin);
if (!unnamedPlus || (key !== "p" && key !== "P") || (registerName && registerName !== "+") || !command || cm.state.vim?.insertMode) return command;
return () => {
const vimState = cm.state.vim;
if (!vimState) return command();
const previousText = registers.unnamedRegister.toString();
const previousLinewise = registers.unnamedRegister.linewise;
pendingPastes.add(cm);
void clipboard
.readText()
.then((text) => {
if (cm.state.vim !== vimState) return;
const linewise = text === previousText ? previousLinewise : text.endsWith("\n");
registers.unnamedRegister.setText(text, linewise, false);
// Explicit + pastes must use the freshly read native clipboard, not
// the library's navigator.clipboard branch.
vimState.inputState.registerName = undefined;
command();
})
.catch((error) => {
console.warn("[vim-config] could not read system clipboard", error);
if (cm.state.vim === vimState) {
vimState.inputState.registerName = undefined;
command();
}
})
.finally(() => {
pendingPastes.delete(cm);
});
return true;
};
};
}
+13
View File
@@ -5,6 +5,19 @@ description: 使用 DBX 编写、理解、参数化、执行、分析和复用 S
查询编辑器是 DBX 的 SQL 工作区。它把方言感知的高亮与补全、语义诊断、参数输入、执行进度、多结果、执行计划、查询历史、SQL 库和 AI 助手放在同一个标签页中。
## Vim 配置
在桌面版的**编辑器设置**中启用 Vim 模式后,DBX 会读取 `~/.dbx/vimrc`。如文件不存在,请先创建;修改后重启 DBX 才会生效。空行及以 `"` 开头的注释行会被忽略。CodeMirror Vim 支持的命令会应用到每个 SQL 编辑器标签页,例如:
```vim
" DBX Vim 映射
imap jj <Esc>
nmap Y y$
set clipboard=unnamedplus
```
配置 `set clipboard=unnamedplus` 后,Vim 的复制和删除会写入系统剪贴板,普通模式下的 `p` 和 `P` 会从系统剪贴板读取。不支持的命令不会阻止编辑器启动。
## 基本工作流
<Steps>
+13
View File
@@ -5,6 +5,19 @@ description: Write, understand, parameterize, execute, analyze, and reuse SQL in
The query editor is the SQL workspace in DBX. It combines dialect-aware highlighting and completion, semantic diagnostics, parameter input, execution progress, multiple results, execution plans, query history, the SQL library, and AI assistance in one tab.
## Vim configuration
In the desktop app, enable Vim mode in **Editor Settings** to load commands from `~/.dbx/vimrc`. Create the file if needed, then restart DBX after editing it. Blank lines and lines beginning with `"` are ignored. Commands supported by CodeMirror Vim, such as these mappings, apply to every SQL editor tab:
```vim
" DBX Vim mappings
imap jj <Esc>
nmap Y y$
set clipboard=unnamedplus
```
With `set clipboard=unnamedplus`, Vim yanks and deletes write to the system clipboard, and normal-mode `p` and `P` read from it. Unsupported commands do not prevent the editor from starting.
## Basic Workflow
<Steps>
+3 -3
View File
@@ -6,11 +6,11 @@ const capability = JSON.parse(readFileSync(new URL("../../src-tauri/capabilities
permissions: (string | ScopedPermission)[];
};
it("grants only exists and readTextFile command scopes for the exact UI tuning file", () => {
it("grants only exists and readTextFile command scopes for the exact UI tuning and Vim config files", () => {
const scopes = capability.permissions.filter((permission): permission is ScopedPermission => typeof permission !== "string" && permission.identifier.startsWith("fs:"));
expect(scopes).toEqual([
{ identifier: "fs:allow-exists", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }] },
{ identifier: "fs:allow-read-text-file", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }] },
{ identifier: "fs:allow-exists", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }, { path: "$HOME/.dbx/vimrc" }] },
{ identifier: "fs:allow-read-text-file", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }, { path: "$HOME/.dbx/vimrc" }] },
]);
expect(capability.permissions).not.toContain("fs:scope");
expect(capability.permissions.filter((permission) => typeof permission === "string" && permission.startsWith("fs:"))).toEqual(["fs:default", "fs:allow-write-file", "fs:allow-write-text-file", "fs:allow-stat", "fs:allow-read-file", "fs:allow-open", "fs:allow-read"]);
+2 -2
View File
@@ -36,13 +36,13 @@
"fs:default",
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }]
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }, { "path": "$HOME/.dbx/vimrc" }]
},
"fs:allow-write-file",
"fs:allow-write-text-file",
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }]
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }, { "path": "$HOME/.dbx/vimrc" }]
},
"fs:allow-stat",
"fs:allow-read-file",