feat(editor): add vim mode

This commit is contained in:
t8y2 committed 2026-07-03 15:38:07 +08:00
1 parent fe4908321e
commit 47238b689c
13 files changed
+136 -3

No files matched your search

@@ -213,6 +213,7 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(settingsStore.editorSettings.sqlSemanticDiagnosticsMode);
const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution);
@@ -512,6 +513,7 @@ watch(
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
@@ -581,6 +583,7 @@ function hasChanges(): boolean {
editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker ||
editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables ||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
editVimModeEnabled.value !== settingsStore.editorSettings.vimModeEnabled ||
editSqlSemanticDiagnosticsMode.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsMode ||
editSqlSemanticDiagnosticsEnabled.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled ||
editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution ||
@@ -636,6 +639,7 @@ async function persistSettings() {
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
autoAliasTables: editAutoAliasTables.value,
wordWrap: editWordWrap.value,
vimModeEnabled: editVimModeEnabled.value,
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
confirmUnsavedSqlClose: editConfirmUnsavedSqlClose.value,
@@ -700,6 +704,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
@@ -765,6 +770,7 @@ function resetAllDefaults() {
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
@@ -2164,6 +2170,14 @@ onUnmounted(cleanupPreviewEditor);
<Switch id="editor-word-wrap" v-model="editWordWrap" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-vim-mode">{{ t("settings.vimMode") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.vimModeDescription") }}</p>
</div>
<Switch id="editor-vim-mode" v-model="editVimModeEnabled" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>
@@ -192,10 +192,16 @@ let codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelectio
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
let vimModeComp: import("@codemirror/state").Compartment | null = null;
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
let completionComp: import("@codemirror/state").Compartment | null = null;
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorVim: typeof import("@replit/codemirror-vim").vim | null = null;
let codeMirrorVimApi: typeof import("@replit/codemirror-vim").Vim | null = null;
let codeMirrorGetVimCm: typeof import("@replit/codemirror-vim").getCM | null = null;
let codeMirrorVimImportPromise: Promise<typeof import("@replit/codemirror-vim")> | null = null;
let dbxVimCommandsConfigured = false;
let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null = null;
@@ -236,6 +242,7 @@ let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
let editorScrollbarPointerCleanup: (() => void) | null = null;
const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18;
const tableNavigationHoverClass = "query-editor--table-navigation-hover";
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
function editorThemeAppearance() {
return isDark.value ? "dark" : "light";
@@ -264,6 +271,7 @@ const queryEditorAppearanceSettings = computed(() => {
customThemes: settings.customThemes,
activeCustomThemeId: settings.activeCustomThemeId,
wordWrap: settings.wordWrap,
vimModeEnabled: settings.vimModeEnabled,
shortcuts: settings.shortcuts,
};
});
@@ -730,6 +738,53 @@ function wordWrapExtension() {
return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : [];
}
function vimModeExtension(enabled = settingsStore.editorSettings.vimModeEnabled) {
if (!codeMirrorVim || !enabled) return [];
const vimExtension = codeMirrorVim({ status: true });
if (!codeMirrorPrec || !editorViewModule || !codeMirrorGetVimCm || !codeMirrorVimApi) return vimExtension;
// Beekeeper treats Vim as a first-class editor keymap. Keep it above DBX's
// normal shortcuts so regular normal-mode keys are not stolen by other maps.
return codeMirrorPrec.highest([
editorViewModule.keymap.of([
{
key: "Ctrl-[",
mac: "Ctrl-[",
linux: "Ctrl-[",
win: "Ctrl-[",
run(currentView) {
const cm = codeMirrorGetVimCm?.(currentView);
if (cm?.state.vim?.insertMode) {
codeMirrorVimApi?.exitInsertMode(cm as any, true);
return true;
}
return false;
},
},
]),
vimExtension,
]);
}
function configureDbxVimCommands(vimApi: typeof import("@replit/codemirror-vim").Vim) {
if (dbxVimCommandsConfigured) return;
dbxVimCommandsConfigured = true;
vimApi.defineEx("write", "w", (cm) => {
cm.cm6?.contentDOM.dispatchEvent(new CustomEvent(DBX_VIM_SAVE_EVENT, { bubbles: true }));
});
}
async function ensureCodeMirrorVim() {
if (codeMirrorVim && codeMirrorVimApi && codeMirrorGetVimCm) return true;
codeMirrorVimImportPromise ??= import("@replit/codemirror-vim");
const { vim, Vim, getCM } = await codeMirrorVimImportPromise;
codeMirrorVim = vim;
codeMirrorVimApi = Vim;
codeMirrorGetVimCm = getCM;
configureDbxVimCommands(Vim);
return true;
}
function indentExtension() {
if (!codeMirrorIndentUnit) return [];
return codeMirrorIndentUnit.of(editorIndentUnit());
@@ -2089,6 +2144,7 @@ onMounted(async () => {
fontThemeComp = new Compartment();
codeMirrorTheme = new Compartment();
wordWrapComp = new Compartment();
vimModeComp = new Compartment();
readOnlyComp = new Compartment();
runKeymapComp = new Compartment();
completionComp = new Compartment();
@@ -2201,6 +2257,9 @@ onMounted(async () => {
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors());
if (initialSettings.vimModeEnabled) {
await ensureCodeMirrorVim();
}
class RunStatementGutterMarker extends GutterMarker {
toDOM() {
@@ -2291,6 +2350,8 @@ onMounted(async () => {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
crosshairCursor(),
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)),
keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]),
langSql.sql({ dialect }),
tooltips({ parent: document.body }),
@@ -2377,6 +2438,10 @@ onMounted(async () => {
window.setTimeout(flushImeComposition, 0);
return false;
},
[DBX_VIM_SAVE_EVENT]() {
emit("save");
return true;
},
wheel(event) {
if (!event.metaKey && !event.ctrlKey) return false;
event.preventDefault();
@@ -2622,7 +2687,7 @@ function getCurrentCustomThemeColors() {
watch(
[queryEditorAppearanceSettings, () => isDark.value],
async ([ss]) => {
if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !runKeymapComp || !editorViewModule) {
if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) {
return;
}
if (!isGestureZooming.value && !zoomCommitScheduler.hasPendingCommit() && liveFontSize.value !== ss.fontSize) {
@@ -2630,9 +2695,17 @@ watch(
}
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
const themeColors = getCurrentCustomThemeColors();
const themeExt = await loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors);
const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]);
if (!view.value || !codeMirrorTheme || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) {
return;
}
view.value.dispatch({
effects: [codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap))],
effects: [
codeMirrorTheme.reconfigure(themeExt),
wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)),
runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)),
],
});
},
{ deep: true },