add: 增加对CodeMirror的设置页

This commit is contained in:
rarnu
2026-05-02 14:58:55 +08:00
parent 0273f9f65d
commit 4af43e35e1
9 changed files with 465 additions and 23 deletions
+13 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, Square, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Bot, Pin, AlignLeft, CloudDownload, ArrowLeftRight, FileCode, Settings } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -32,6 +32,7 @@ import AiAssistant from "@/components/editor/AiAssistant.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import QueryHistory from "@/components/editor/QueryHistory.vue";
import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue";
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
@@ -60,6 +61,7 @@ const settingsStore = useSettingsStore();
const { message: toastMessage, visible: toastVisible, toast } = useToast();
const showConnectionDialog = ref(false);
const showSettingsDialog = ref(false);
const showHistory = ref(false);
const showAiPanel = ref(localStorage.getItem("dbx-ai-panel-open") !== "false");
const aiPanelWidth = ref(Number(localStorage.getItem("dbx-ai-panel-width")) || 360);
@@ -785,6 +787,15 @@ async function setupFileDrop() {
</TooltipTrigger>
<TooltipContent>GitHub</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showSettingsDialog = true">
<Settings class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.title') }}</TooltipContent>
</Tooltip>
</div>
<!-- Main Content -->
@@ -1194,6 +1205,7 @@ async function setupFileDrop() {
@connect-succeeded="onConnectionConnectSucceeded"
@connect-failed="onConnectionConnectFailed"
/>
<EditorSettingsDialog v-model:open="showSettingsDialog" />
<DangerConfirmDialog v-model:open="showDangerDialog" :sql="dangerSql" @confirm="onDangerConfirm" />
<DataTransferDialog
v-model:open="showTransferDialog"
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Settings } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useSettingsStore, EDITOR_THEMES, FONT_FAMILIES, type EditorTheme } from "@/stores/settingsStore";
const { t } = useI18n();
const settingsStore = useSettingsStore();
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
// Local edit state
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
const editTheme = ref(settingsStore.editorSettings.theme);
// Sync from store when dialog opens
watch(() => props.open, (open) => {
if (open) {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editTheme.value = settingsStore.editorSettings.theme;
}
});
function hasChanges(): boolean {
return (
editFontFamily.value !== settingsStore.editorSettings.fontFamily ||
editFontSize.value !== settingsStore.editorSettings.fontSize ||
editTheme.value !== settingsStore.editorSettings.theme
);
}
function applySettings() {
settingsStore.updateEditorSettings({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
theme: editTheme.value,
});
emit("update:open", false);
}
function resetDefaults() {
editFontFamily.value = "'JetBrains Mono', 'Fira Code', monospace";
editFontSize.value = 13;
editTheme.value = "one-dark";
}
function onFontFamilyChange(v: any) {
if (typeof v === 'string') editFontFamily.value = v;
}
function onThemeChange(v: any) {
if (typeof v === 'string') editTheme.value = v as EditorTheme;
}
</script>
<template>
<Dialog :open="open" @update:open="(v: boolean) => emit('update:open', v)">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Settings class="h-4 w-4" />
{{ t('settings.title') }}
</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- Font Family -->
<div class="space-y-2">
<Label>{{ t('settings.fontFamily') }}</Label>
<Select :model-value="editFontFamily" @update:model-value="onFontFamilyChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.selectFont')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="font in FONT_FAMILIES"
:key="font.value"
:value="font.value"
:style="{ fontFamily: font.value }"
>
{{ font.label }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground leading-relaxed font-mono" :style="{ fontFamily: editFontFamily }">
SELECT * FROM users WHERE id = 1;
</p>
</div>
<Separator />
<!-- Font Size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>{{ t('settings.fontSize') }}</Label>
<span class="text-xs text-muted-foreground tabular-nums">{{ editFontSize }}px</span>
</div>
<input
type="range"
min="10"
max="24"
step="1"
:value="editFontSize"
@input="editFontSize = Number(($event.target as HTMLInputElement).value)"
class="w-full accent-primary"
/>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>10px</span>
<span class="flex-1 border-b border-dashed border-muted-foreground/30" />
<span>24px</span>
</div>
</div>
<Separator />
<!-- Theme -->
<div class="space-y-2">
<Label>{{ t('settings.theme') }}</Label>
<Select :model-value="editTheme" @update:model-value="onThemeChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.selectTheme')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="theme in EDITOR_THEMES"
:key="theme.value"
:value="theme.value"
>
<div class="flex items-center gap-2">
<span
class="h-3 w-3 rounded-full border"
:class="theme.dark ? 'bg-foreground border-foreground/20' : 'bg-muted-foreground/30 border-muted-foreground/40'"
/>
{{ theme.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter class="gap-2 sm:gap-0">
<Button variant="outline" @click="resetDefaults">
{{ t('settings.resetDefaults') }}
</Button>
<div class="flex-1" />
<Button variant="outline" @click="emit('update:open', false)">
{{ t('common.close') }}
</Button>
<Button :disabled="!hasChanges()" @click="applySettings">
{{ t('settings.apply') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+68 -20
View File
@@ -2,9 +2,11 @@
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore, type EditorTheme } from "@/stores/settingsStore";
import {
buildSqlCompletionItemsFromContext,
getSqlCompletionContext,
@@ -29,48 +31,72 @@ const emit = defineEmits<{
const editorRef = ref<HTMLDivElement>();
const view = shallowRef<EditorViewType | null>(null);
const connectionStore = useConnectionStore();
const DEFAULT_FONT_SIZE = 13;
const settingsStore = useSettingsStore();
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
let editorViewModule: typeof import("@codemirror/view") | null = null;
let fontSizeTheme: import("@codemirror/state").Compartment | null = null;
let fontFamilyTheme: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
const savedFontSize = Number(localStorage.getItem("dbx-query-editor-font-size"));
const fontSize = ref(
Number.isFinite(savedFontSize)
? Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, savedFontSize))
: DEFAULT_FONT_SIZE,
);
async function loadTheme(theme: EditorTheme): Promise<Extension> {
switch (theme) {
case "one-dark":
return (await import("@codemirror/theme-one-dark")).oneDark;
case "vscode-dark":
return (await import("@uiw/codemirror-theme-vscode")).vscodeDark;
case "vscode-light":
return (await import("@uiw/codemirror-theme-vscode")).vscodeLight;
case "nord":
return (await import("@uiw/codemirror-theme-nord")).nord;
case "okaidia":
return (await import("@uiw/codemirror-theme-okaidia")).okaidia;
case "material":
return (await import("@uiw/codemirror-theme-material")).materialDark;
case "duotone-light":
return (await import("@uiw/codemirror-theme-duotone")).duotoneLight;
case "duotone-dark":
return (await import("@uiw/codemirror-theme-duotone")).duotoneDark;
case "xcode":
return (await import("@uiw/codemirror-theme-xcode")).xcodeLight;
default:
return (await import("@codemirror/theme-one-dark")).oneDark;
}
}
function fontTheme(EditorView: typeof import("@codemirror/view").EditorView, size: number) {
function fontTheme(EditorView: typeof import("@codemirror/view").EditorView, size: number, family: string) {
return EditorView.theme({
"&": { height: "100%", fontSize: `${size}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
".cm-content": { fontFamily: family },
});
}
function setFontSize(size: number) {
const next = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size));
fontSize.value = next;
localStorage.setItem("dbx-query-editor-font-size", String(next));
if (view.value && fontSizeTheme && editorViewModule) {
const ss = settingsStore.editorSettings;
ss.fontSize = next;
settingsStore.updateEditorSettings({ fontSize: next });
if (view.value && fontSizeTheme && fontFamilyTheme && editorViewModule) {
view.value.dispatch({
effects: fontSizeTheme.reconfigure(fontTheme(editorViewModule.EditorView, next)),
effects: [
fontSizeTheme.reconfigure(fontTheme(editorViewModule.EditorView, next, ss.fontFamily)),
fontFamilyTheme.reconfigure(fontTheme(editorViewModule.EditorView, next, ss.fontFamily)),
],
});
}
}
function zoomIn() {
setFontSize(fontSize.value + 1);
setFontSize(settingsStore.editorSettings.fontSize + 1);
}
function zoomOut() {
setFontSize(fontSize.value - 1);
setFontSize(settingsStore.editorSettings.fontSize - 1);
}
function resetZoom() {
setFontSize(DEFAULT_FONT_SIZE);
setFontSize(13);
}
function selectedSqlFromView(currentView: EditorViewType): string {
@@ -162,18 +188,20 @@ onMounted(async () => {
{ EditorState, Compartment },
{ sql, MySQL, PostgreSQL, SQLDialect },
{ basicSetup },
{ oneDark },
{ autocompletion, startCompletion },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-sql"),
import("codemirror"),
import("@codemirror/theme-one-dark"),
import("@codemirror/autocomplete"),
]);
editorViewModule = { EditorView, keymap } as typeof import("@codemirror/view");
fontSizeTheme = new Compartment();
fontFamilyTheme = new Compartment();
codeMirrorTheme = new Compartment();
const ss = settingsStore.editorSettings;
const baseDialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
@@ -220,6 +248,8 @@ onMounted(async () => {
},
]);
const theme = await loadTheme(ss.theme);
const state = EditorState.create({
doc: props.modelValue,
extensions: [
@@ -231,7 +261,7 @@ onMounted(async () => {
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos),
],
}),
oneDark,
codeMirrorTheme.of(theme),
runKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
@@ -248,7 +278,8 @@ onMounted(async () => {
emit("selectionChange", selectedSqlFromView(update.view));
}
}),
fontSizeTheme.of(fontTheme(EditorView, fontSize.value)),
fontSizeTheme.of(fontTheme(EditorView, ss.fontSize, ss.fontFamily)),
fontFamilyTheme.of(fontTheme(EditorView, ss.fontSize, ss.fontFamily)),
EditorView.domEventHandlers({
wheel(event) {
if (!event.metaKey && !event.ctrlKey) return false;
@@ -282,6 +313,23 @@ watch(
}
);
// Reactively apply editor settings changes (theme, font family, font size)
watch(
() => settingsStore.editorSettings,
async (ss) => {
if (!view.value || !codeMirrorTheme || !fontFamilyTheme || !fontSizeTheme || !editorViewModule) return;
const themeExt = await loadTheme(ss.theme);
view.value.dispatch({
effects: [
codeMirrorTheme.reconfigure(themeExt),
fontFamilyTheme.reconfigure(fontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)),
fontSizeTheme.reconfigure(fontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)),
],
});
},
{ deep: true }
);
onBeforeUnmount(() => {
view.value?.destroy();
});
+10
View File
@@ -358,4 +358,14 @@ export default {
executeSync: "Execute Sync",
syncSuccess: "Sync executed successfully",
},
settings: {
title: "Settings",
fontFamily: "Font Family",
selectFont: "Select font...",
fontSize: "Font Size",
theme: "Theme",
selectTheme: "Select theme...",
apply: "Apply",
resetDefaults: "Reset Defaults",
},
};
+10
View File
@@ -358,4 +358,14 @@ export default {
executeSync: "执行同步",
syncSuccess: "同步执行成功",
},
settings: {
title: "设置",
fontFamily: "字体",
selectFont: "选择字体...",
fontSize: "字号",
theme: "主题",
selectTheme: "选择主题...",
apply: "应用",
resetDefaults: "恢复默认",
},
};
+68 -1
View File
@@ -19,10 +19,69 @@ const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = {
custom: { provider: "custom", endpoint: "", model: "", apiStyle: "completions" },
};
export type EditorTheme = "one-dark" | "vscode-dark" | "vscode-light" | "nord" | "okaidia" | "material" | "duotone-light" | "duotone-dark" | "xcode";
export interface EditorSettings {
fontFamily: string;
fontSize: number;
theme: EditorTheme;
}
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
{ value: "one-dark", label: "One Dark", dark: true },
{ value: "vscode-dark", label: "VS Dark+", dark: true },
{ value: "vscode-light", label: "VS Light+", dark: false },
{ value: "nord", label: "Nord", dark: true },
{ value: "okaidia", label: "Okaidia", dark: true },
{ value: "material", label: "Material", dark: true },
{ value: "duotone-light", label: "Duotone Light", dark: false },
{ value: "duotone-dark", label: "Duotone Dark", dark: true },
{ value: "xcode", label: "Xcode", dark: false },
];
export const FONT_FAMILIES: { value: string; label: string }[] = [
{ value: "'JetBrains Mono', 'Fira Code', monospace", label: "JetBrains Mono" },
{ value: "'Fira Code', monospace", label: "Fira Code" },
{ value: "'Cascadia Code', monospace", label: "Cascadia Code" },
{ value: "'Source Code Pro', monospace", label: "Source Code Pro" },
{ value: "'SF Mono', 'Menlo', monospace", label: "SF Mono / Menlo" },
{ value: "'Consolas', 'Courier New', monospace", label: "Consolas" },
{ value: "monospace", label: "System Monospace" },
];
const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 13,
theme: "one-dark",
};
export const STORAGE_KEY = "dbx-editor-settings";
function loadEditorSettings(): EditorSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<EditorSettings>;
return {
fontFamily: parsed.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
fontSize: parsed.fontSize ?? DEFAULT_EDITOR_SETTINGS.fontSize,
theme: parsed.theme ?? DEFAULT_EDITOR_SETTINGS.theme,
};
}
} catch { /* ignore */ }
return { ...DEFAULT_EDITOR_SETTINGS };
}
function saveEditorSettings(settings: EditorSettings) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
export const useSettingsStore = defineStore("settings", () => {
const aiConfig = ref<AiConfig>({ ...defaultConfigs.claude, apiKey: "", apiStyle: "completions" });
const isAiConfigLoaded = ref(false);
const editorSettings = ref<EditorSettings>(loadEditorSettings());
async function initAiConfig() {
if (isAiConfigLoaded.value) return;
const legacy = localStorage.getItem("dbx-ai-config");
@@ -50,5 +109,13 @@ export const useSettingsStore = defineStore("settings", () => {
return !!aiConfig.value.apiKey && !!aiConfig.value.endpoint;
}
return { aiConfig, isAiConfigLoaded, initAiConfig, updateAiConfig, isConfigured };
function updateEditorSettings(partial: Partial<EditorSettings>) {
Object.assign(editorSettings.value, partial);
saveEditorSettings(editorSettings.value);
}
return {
aiConfig, isAiConfigLoaded, initAiConfig, updateAiConfig, isConfigured,
editorSettings, updateEditorSettings,
};
});