feat: custom theme system with PostgreSQL PL/pgSQL highlighting fix

- Fix PostgreSQL function highlighting by disabling doubleDollarQuotedStrings
  in extended PostgreSQL dialect, enabling PL/pgSQL syntax highlighting
  inside $ blocks
- Add complete custom editor theme system with multi-theme management
  (create, rename, duplicate, delete), visual color editor (12 colors),
  JSON import/export, real-time preview, and 12 preset color schemes
- Add background/foreground color customization with system theme defaults
- Optimize EditorSettingsDialog layout: 2-column grid with font selector
  taking available space and theme dropdown grouped with custom theme button
- Add i18n support for custom theme UI (en, zh-CN, zh-TW, es)
- Fix Tauri production build by adding custom-protocol feature
- Update .gitignore for temporary build artifacts

Closes t8y2#788

Co-authored-by: Sam <14344444@@qq.com>
This commit is contained in:
polempandSam authored and GitHub committed 2026-06-06 20:59:21 +08:00
1 parent 0b07f05f72
commit a067ee8d76
15 files changed
+1190 -43

No files matched your search

@@ -44,8 +44,11 @@ import {
type EditorTheme,
type DesktopIconTheme,
type DisconnectTabHandlingMode,
type CustomThemeColors,
type CustomTheme,
} from "@/stores/settingsStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/clipboard";
@@ -103,6 +106,9 @@ const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
const editUiScale = ref(settingsStore.editorSettings.uiScale);
const editTheme = ref(settingsStore.editorSettings.theme);
const editCustomThemes = ref<CustomTheme[]>([...settingsStore.editorSettings.customThemes]);
const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThemeId);
const showThemeCustomizer = ref(false);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution);
@@ -256,6 +262,8 @@ watch(
editFontSize.value = settingsStore.editorSettings.fontSize;
editUiScale.value = settingsStore.editorSettings.uiScale;
editTheme.value = settingsStore.editorSettings.theme;
editCustomThemes.value = [...settingsStore.editorSettings.customThemes];
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
@@ -300,6 +308,8 @@ function hasChanges(): boolean {
editFontSize.value !== settingsStore.editorSettings.fontSize ||
editUiScale.value !== settingsStore.editorSettings.uiScale ||
editTheme.value !== settingsStore.editorSettings.theme ||
JSON.stringify(editCustomThemes.value) !== JSON.stringify(settingsStore.editorSettings.customThemes) ||
editActiveCustomThemeId.value !== settingsStore.editorSettings.activeCustomThemeId ||
editExecuteMode.value !== settingsStore.editorSettings.executeMode ||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution ||
@@ -333,6 +343,8 @@ async function persistSettings() {
fontSize: editFontSize.value,
uiScale: editUiScale.value,
theme: editTheme.value,
customThemes: editCustomThemes.value,
activeCustomThemeId: editActiveCustomThemeId.value,
executeMode: editExecuteMode.value,
wordWrap: editWordWrap.value,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
@@ -375,6 +387,8 @@ function resetDefaults() {
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editUiScale.value = DEFAULT_EDITOR_SETTINGS.uiScale;
editTheme.value = DEFAULT_EDITOR_SETTINGS.theme;
editCustomThemes.value = [...DEFAULT_EDITOR_SETTINGS.customThemes];
editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
@@ -405,8 +419,43 @@ function onFontFamilyChange(v: any) {
if (typeof v === "string") editFontFamily.value = v;
}
const themeSelectValue = computed(() => {
if (editTheme.value === "custom") {
return `custom:${editActiveCustomThemeId.value}`;
}
return editTheme.value;
});
const themeSelectOptions = computed(() => [
...EDITOR_THEMES.filter((theme) => theme.value !== "custom").map((theme) => ({
value: theme.value,
label: theme.value === "app" ? t("settings.followAppTheme") : theme.label,
dark: theme.dark,
isCustom: false,
})),
...editCustomThemes.value.map((theme) => ({
value: `custom:${theme.id}`,
label: theme.name,
dark: true,
isCustom: true,
})),
]);
function onThemeChange(v: any) {
if (typeof v === "string") editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
if (typeof v !== "string") return;
if (v.startsWith("custom:")) {
editTheme.value = "custom";
editActiveCustomThemeId.value = v.slice(7);
} else {
editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
}
}
function handleThemeSave(updatedThemes: CustomTheme[], activeId: string) {
editCustomThemes.value = updatedThemes;
editActiveCustomThemeId.value = activeId;
editTheme.value = "custom";
showThemeCustomizer.value = false;
}
function onDisconnectTabHandlingModeChange(v: any) {
@@ -1036,16 +1085,24 @@ async function aiTestConn() {
const previewRef = ref<HTMLDivElement>();
const previewView = shallowRef<EditorViewType | null>(null);
function getPreviewCustomThemeColors(): CustomThemeColors | undefined {
if (editTheme.value !== "custom") return undefined;
const activeTheme = editCustomThemes.value.find((t) => t.id === editActiveCustomThemeId.value);
return activeTheme?.colors;
}
const previewSettings = computed<{
fontFamily: string;
fontSize: number;
theme: EditorTheme;
appAppearance: AppThemeAppearance;
customColors?: CustomThemeColors;
}>(() => ({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
theme: editTheme.value,
appAppearance: isDark.value ? "dark" : "light",
customColors: getPreviewCustomThemeColors(),
}));
const previewSql = `SELECT u.id, u.name
@@ -1057,11 +1114,11 @@ let themeComp: import("@codemirror/state").Compartment | null = null;
let editorViewModule: typeof import("@codemirror/view") | null = null;
watch(
previewSettings,
async (ss) => {
[previewSettings, editCustomThemes, editActiveCustomThemeId],
async ([ss]) => {
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance);
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors);
previewView.value.dispatch({
effects: [
themeComp.reconfigure(themeExt),
@@ -1102,7 +1159,7 @@ watch(previewRef, async (el) => {
themeComp = new Compartment();
const ss = previewSettings.value;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance);
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors);
const state = EditorState.create({
doc: previewSql,
@@ -1160,9 +1217,9 @@ watch(
<div class="min-w-0 flex-1 overflow-hidden px-1 flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pr-2">
<section v-if="activeSettingsTab === 'editor'" class="flex flex-col gap-5 py-2">
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div class="grid gap-4 md:grid-cols-[1fr_auto]">
<!-- Font Family -->
<div class="space-y-2">
<div class="space-y-2 min-w-0">
<Label>{{ t("settings.fontFamily") }}</Label>
<SearchableSelect
:model-value="editFontFamily"
@@ -1196,29 +1253,40 @@ watch(
</SearchableSelect>
</div>
<!-- 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.value === "app" ? t("settings.followAppTheme") : theme.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
<!-- Theme + Custom Theme Button -->
<div class="flex gap-2 items-end">
<div class="space-y-2">
<Label>{{ t("settings.theme") }}</Label>
<Select :model-value="themeSelectValue" @update:model-value="onThemeChange">
<SelectTrigger class="min-w-[80px] max-w-[200px]">
<SelectValue :placeholder="t('settings.selectTheme')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="theme in themeSelectOptions" :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>
<Button
v-if="editTheme === 'custom'"
variant="outline"
class="h-9 w-auto px-4"
@click="showThemeCustomizer = true"
>
<Settings class="mr-2 h-4 w-4" />
{{ t("settings.customThemeConfigure") }}
</Button>
</div>
</div>
@@ -2548,6 +2616,14 @@ watch(
</div>
</DialogContent>
<!-- Theme Customizer Dialog -->
<ThemeCustomizerDialog
v-model:open="showThemeCustomizer"
:themes="editCustomThemes"
:active-theme-id="editActiveCustomThemeId"
@save="handleThemeSave"
/>
<!-- Snippet Add/Edit Dialog -->
<Dialog :open="snippetDialogOpen" @update:open="snippetDialogOpen = $event">
<DialogContent class="sm:max-w-[500px]">
@@ -1,5 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed } from "vue";
import {
ref,
onMounted,
onBeforeUnmount,
onActivated,
onDeactivated,
watch,
shallowRef,
computed,
nextTick,
} from "vue";
import { Play, Copy, TextSelect } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
@@ -1405,17 +1415,28 @@ onMounted(async () => {
],
});
const ss = settingsStore.editorSettings;
const baseDialect = props.dialect === "postgres" ? PostgreSQL : props.dialect === "sqlserver" ? MSSQL : 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";
// PL/pgSQL extension: add procedural language keywords and built-in variables for PostgreSQL function/procedure bodies
const isPostgres = props.dialect === "postgres";
const plpgsqlKeywords = isPostgres ? "PERFORM" : "";
const plpgsqlTypes = isPostgres ? " RECORD JSON JSONB" : "";
const plpgsqlBuiltin = isPostgres
? "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV"
: "";
const dialect = SQLDialect.define({
...baseDialect.spec,
keywords: (baseDialect.spec.keywords || "") + " " + extraKeywords,
keywords: [baseDialect.spec.keywords || "", extraKeywords, plpgsqlKeywords].filter(Boolean).join(" "),
types: [baseDialect.spec.types || "", plpgsqlTypes].filter(Boolean).join(" ") || undefined,
builtin: [baseDialect.spec.builtin || "", plpgsqlBuiltin].filter(Boolean).join(" ") || undefined,
doubleDollarQuotedStrings: false,
});
const theme = await loadEditorTheme(ss.theme, editorThemeAppearance());
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors());
const activeLineHighlighter = ViewPlugin.fromClass(
class {
@@ -1496,7 +1517,7 @@ onMounted(async () => {
]),
),
runKeymapComp.of(runKeymapExtension(keymap)),
wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []),
wordWrapComp.of(props.forceWordWrap || initialSettings.wordWrap ? EditorView.lineWrapping : []),
readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }),
EditorView.updateListener.of((update) => {
@@ -1518,7 +1539,7 @@ onMounted(async () => {
}
}),
fontThemeComp.of(
editorFontTheme(EditorView, liveFontSize.value, ss.fontFamily, {
editorFontTheme(EditorView, liveFontSize.value, initialSettings.fontFamily, {
fixedHeight: true,
scrollable: true,
}),
@@ -1678,12 +1699,23 @@ onMounted(async () => {
view.value = new EditorView({ state, parent: editorRef.value });
syncContextMenuState(view.value);
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
registerTableReferenceDropListener();
cachedTables = [];
cachedCompletionObjects = [];
scheduleSemanticDiagnostics();
// Ensure theme is applied with the latest settings after mount
void nextTick(async () => {
if (!view.value || !codeMirrorTheme) return;
const settings = settingsStore.editorSettings;
const themeColors = settings.theme === "custom" ? getCurrentCustomThemeColors() : settings.customThemeColors;
const themeExt = await loadEditorTheme(settings.theme, editorThemeAppearance(), themeColors);
view.value.dispatch({
effects: [codeMirrorTheme.reconfigure(themeExt)],
});
});
});
watch(
@@ -1748,6 +1780,16 @@ watch(
},
);
// Derive current custom theme colors from settingsStore
function getCurrentCustomThemeColors() {
const settings = settingsStore.editorSettings;
if (settings.theme !== "custom") return settings.customThemeColors;
const activeTheme =
settings.customThemes?.find((t: { id: string }) => t.id === settings.activeCustomThemeId) ||
settings.customThemes?.[0];
return activeTheme?.colors ?? settings.customThemeColors;
}
// Reactively apply editor settings changes
watch(
[() => settingsStore.editorSettings, () => isDark.value],
@@ -1759,7 +1801,8 @@ watch(
liveFontSize.value = ss.fontSize;
}
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
const themeExt = await loadEditorTheme(ss.theme, editorThemeAppearance());
const themeColors = getCurrentCustomThemeColors();
const themeExt = await loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors);
view.value.dispatch({
effects: [
codeMirrorTheme.reconfigure(themeExt),
@@ -0,0 +1,621 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import type { CustomTheme, CustomThemeColors } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_COLORS } from "@/stores/settingsStore";
import { Plus, Trash2, Copy, Pencil, ChevronDown, Palette } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useI18n } from "vue-i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Props {
open: boolean;
themes: CustomTheme[];
activeThemeId: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "save", themes: CustomTheme[], activeId: string): void;
}>();
const { toast } = useToast();
const { t } = useI18n();
const localThemes = ref<CustomTheme[]>([]);
const activeEditId = ref("");
const jsonText = ref("");
const renamingId = ref<string | null>(null);
const renamingName = ref("");
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
localThemes.value = JSON.parse(JSON.stringify(props.themes));
activeEditId.value = props.activeThemeId;
syncJson();
}
},
);
function syncJson() {
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
jsonText.value = JSON.stringify(theme?.colors ?? DEFAULT_CUSTOM_THEME_COLORS, null, 2);
}
watch(activeEditId, syncJson);
const activeTheme = computed(() => localThemes.value.find((t) => t.id === activeEditId.value));
const localColors = computed({
get: () => activeTheme.value?.colors ?? { ...DEFAULT_CUSTOM_THEME_COLORS },
set: (colors: CustomThemeColors) => {
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (theme) theme.colors = { ...colors };
},
});
watch(
localThemes,
() => {
syncJson();
},
{ deep: true },
);
const colorItems = [
{ key: "keyword" as const, label: t("settings.customThemeKeyword"), example: "SELECT, WHERE, IF", num: "①" },
{ key: "field" as const, label: t("settings.customThemeField"), example: "id, name, _var", num: "②" },
{ key: "function" as const, label: t("settings.customThemeFunction"), example: "count, upper", num: "③" },
{ key: "string" as const, label: t("settings.customThemeString"), example: "'hello', 'world'", num: "④" },
{ key: "number" as const, label: t("settings.customThemeNumber"), example: "100, 3.14", num: "⑤" },
{ key: "comment" as const, label: t("settings.customThemeComment"), example: "--, /* */", num: "⑥" },
{ key: "table" as const, label: t("settings.customThemeTable"), example: "users, orders", num: "⑦" },
{ key: "operator" as const, label: t("settings.customThemeOperator"), example: "=, >, <>", num: "⑧" },
{ key: "type" as const, label: t("settings.customThemeType"), example: "INTEGER, TEXT", num: "⑨" },
{ key: "builtin" as const, label: t("settings.customThemeBuiltin"), example: "FOUND, SQLERRM", num: "⑩" },
{ key: "background" as const, label: t("settings.customThemeBackground"), example: "Editor background", num: "⑪" },
{ key: "foreground" as const, label: t("settings.customThemeForeground"), example: "Default text color", num: "⑫" },
];
// Preset color themes (including all built-in themes)
const presetThemes = [
{
name: "Default",
colors: { ...DEFAULT_CUSTOM_THEME_COLORS },
},
{
name: "One Dark",
colors: {
keyword: "#c678dd",
field: "#e06c75",
function: "#61afef",
string: "#98c379",
number: "#d19a66",
comment: "#5c6370",
table: "#98c379",
operator: "#56b6c2",
type: "#e5c07b",
builtin: "#61afef",
background: "#282c34",
foreground: "#abb2bf",
},
},
{
name: "VS Code Dark+",
colors: {
keyword: "#569cd6",
field: "#9cdcfe",
function: "#dcdcaa",
string: "#ce9178",
number: "#b5cea8",
comment: "#6a9955",
table: "#ce9178",
operator: "#d4d4d4",
type: "#4ec9b0",
builtin: "#dcdcaa",
background: "#1e1e1e",
foreground: "#9cdcfe",
},
},
{
name: "Nord",
colors: {
keyword: "#5e81ac",
field: "#88c0d0",
function: "#8fbcbb",
string: "#a3be8c",
number: "#b48ead",
comment: "#4c566a",
table: "#a3be8c",
operator: "#a3be8c",
type: "#ebcb8b",
builtin: "#8fbcbb",
background: "#2e3440",
foreground: "#d8dee9",
},
},
{
name: "Okaidia",
colors: {
keyword: "#f92672",
field: "#a6e22e",
function: "#fd971f",
string: "#e6db74",
number: "#ae81ff",
comment: "#8292a2",
table: "#e6db74",
operator: "#f92672",
type: "#66d9ef",
builtin: "#fd971f",
background: "#272822",
foreground: "#f8f8f2",
},
},
{
name: "Material",
colors: {
keyword: "#cf6edf",
field: "#56c8d8",
function: "#56c8d8",
string: "#a3be8c",
number: "#ffad42",
comment: "#808080",
table: "#a3be8c",
operator: "#cf6edf",
type: "#ffad42",
builtin: "#56c8d8",
background: "#2e3235",
foreground: "#bdbdbd",
},
},
{
name: "Dracula",
colors: {
keyword: "#ff79c6",
field: "#8be9fd",
function: "#50fa7b",
string: "#f1fa8c",
number: "#bd93f9",
comment: "#6272a4",
table: "#f1fa8c",
operator: "#ff79c6",
type: "#8be9fd",
builtin: "#50fa7b",
background: "#282a36",
foreground: "#f8f8f2",
},
},
{
name: "Solarized Dark",
colors: {
keyword: "#cb4b16",
field: "#268bd2",
function: "#b58900",
string: "#2aa198",
number: "#d33682",
comment: "#586e75",
table: "#2aa198",
operator: "#cb4b16",
type: "#859900",
builtin: "#b58900",
background: "#002b36",
foreground: "#839496",
},
},
{
name: "VS Code Light+",
colors: {
keyword: "#0000ff",
field: "#001080",
function: "#795e26",
string: "#a31515",
number: "#098658",
comment: "#008000",
table: "#a31515",
operator: "#000000",
type: "#267f99",
builtin: "#795e26",
background: "#ffffff",
foreground: "#000000",
},
},
{
name: "Duotone Light",
colors: {
keyword: "#6e4cbd",
field: "#1a1a1a",
function: "#6e4cbd",
string: "#6e4cbd",
number: "#6e4cbd",
comment: "#a0a0a0",
table: "#6e4cbd",
operator: "#1a1a1a",
type: "#6e4cbd",
builtin: "#6e4cbd",
background: "#faf8f5",
foreground: "#1a1a1a",
},
},
{
name: "Duotone Dark",
colors: {
keyword: "#9375f5",
field: "#ddd",
function: "#9375f5",
string: "#9375f5",
number: "#9375f5",
comment: "#777",
table: "#9375f5",
operator: "#ddd",
type: "#9375f5",
builtin: "#9375f5",
background: "#2a2734",
foreground: "#ddd",
},
},
{
name: "Xcode",
colors: {
keyword: "#ad3da4",
field: "#5c2699",
function: "#3d1c77",
string: "#d12f1b",
number: "#272ad8",
comment: "#9ba2aa",
table: "#d12f1b",
operator: "#000000",
type: "#234d97",
builtin: "#3d1c77",
background: "#ffffff",
foreground: "#000000",
},
},
];
const selectedPreset = ref("");
function applyPreset() {
const preset = presetThemes.find((p) => p.name === selectedPreset.value);
if (!preset) return;
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (theme) {
theme.colors = { ...DEFAULT_CUSTOM_THEME_COLORS, ...preset.colors };
toast(t("settings.customThemeApplied", { name: preset.name }), 2000);
}
}
// Basic palette colors (similar to Windows color picker)
const basicColors = [
["#000000", "#7f7f7f", "#880015", "#ed1c24", "#ff7f27", "#fff200", "#22b14c", "#00a2e8"],
["#3f48cc", "#a349a4", "#ffffff", "#c3c3c3", "#b97a57", "#ffaec9", "#ffc90e", "#efe4b0"],
];
const expandedPalette = ref<string | null>(null);
function togglePalette(key: string) {
expandedPalette.value = expandedPalette.value === key ? null : key;
}
function applyBasicColor(key: keyof CustomThemeColors, color: string) {
handleColorChange(key, color);
expandedPalette.value = null;
}
const previewCode = computed(() => {
const c = localColors.value;
return [
{ text: "SELECT ", color: c.keyword, num: "①" },
{ text: "id", color: c.field, num: "②" },
{ text: ", ", color: c.operator, num: "⑧" },
{ text: "count", color: c.function, num: "③" },
{ text: "(*) ", color: c.operator, num: "⑧" },
{ text: "FROM ", color: c.keyword, num: "①" },
{ text: "users", color: c.table, num: "⑦" },
{ text: " ", color: "", num: "" },
{ text: "WHERE ", color: c.keyword, num: "①" },
{ text: "status", color: c.field, num: "②" },
{ text: " = ", color: c.operator, num: "⑧" },
{ text: "'active'", color: c.string, num: "④" },
{ text: " ", color: "", num: "" },
{ text: "AND ", color: c.keyword, num: "①" },
{ text: "id", color: c.field, num: "②" },
{ text: " > ", color: c.operator, num: "⑧" },
{ text: "100", color: c.number, num: "⑤" },
{ text: ";", color: c.operator, num: "⑧" },
];
});
function handleColorChange(key: keyof CustomThemeColors, value: string) {
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (theme) {
theme.colors = { ...theme.colors, [key]: value };
}
}
function handleJsonChange() {
try {
const parsed = JSON.parse(jsonText.value);
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (theme) {
theme.colors = { ...DEFAULT_CUSTOM_THEME_COLORS, ...parsed };
}
} catch {
// Invalid JSON, ignore
}
}
function handleSave() {
emit(
"save",
localThemes.value.map((t) => ({ ...t })),
activeEditId.value,
);
emit("update:open", false);
}
function handleAddTheme() {
const id = `custom-${Date.now()}`;
const name = `${t("settings.customThemeDefaultName")} ${localThemes.value.length + 1}`;
localThemes.value.push({
id,
name,
colors: { ...DEFAULT_CUSTOM_THEME_COLORS },
});
activeEditId.value = id;
}
function handleDeleteTheme(id: string) {
if (localThemes.value.length <= 1) {
toast(t("settings.customThemeKeepOne"), 3000);
return;
}
localThemes.value = localThemes.value.filter((t) => t.id !== id);
if (activeEditId.value === id) {
activeEditId.value = localThemes.value[0]?.id ?? "";
}
}
function handleDuplicateTheme(theme: CustomTheme) {
const id = `custom-${Date.now()}`;
localThemes.value.push({
id,
name: `${theme.name}${t("settings.customThemeCopySuffix")}`,
colors: { ...theme.colors },
});
activeEditId.value = id;
}
function startRename(theme: CustomTheme) {
renamingId.value = theme.id;
renamingName.value = theme.name;
}
function confirmRename() {
if (!renamingId.value) return;
const theme = localThemes.value.find((t) => t.id === renamingId.value);
if (theme && renamingName.value.trim()) {
theme.name = renamingName.value.trim();
}
renamingId.value = null;
renamingName.value = "";
}
function cancelRename() {
renamingId.value = null;
renamingName.value = "";
}
function handleExport() {
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (!theme) return;
const blob = new Blob([JSON.stringify(theme.colors, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dbx-theme-${theme.name}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
try {
const parsed = JSON.parse(jsonText.value);
const theme = localThemes.value.find((t) => t.id === activeEditId.value);
if (theme) {
theme.colors = { ...DEFAULT_CUSTOM_THEME_COLORS, ...parsed };
}
} catch (e) {
toast(t("settings.customThemeJsonError"), 3000);
}
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[860px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{{ t("settings.customThemeTitle") }}</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 flex gap-4">
<!-- Theme list sidebar -->
<div class="w-48 shrink-0 flex flex-col gap-2">
<div class="text-sm font-medium px-1">{{ t("settings.customThemeMyThemes") }}</div>
<div class="flex-1 overflow-y-auto space-y-1 pr-1">
<div
v-for="theme in localThemes"
:key="theme.id"
class="group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer text-sm"
:class="activeEditId === theme.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="activeEditId = theme.id"
>
<div class="flex-1 min-w-0">
<div v-if="renamingId === theme.id" class="flex items-center gap-1" @click.stop>
<Input
v-model="renamingName"
class="h-6 text-xs px-1 py-0"
@keydown.enter="confirmRename"
@keydown.esc="cancelRename"
@blur="confirmRename"
autofocus
/>
</div>
<div v-else class="truncate">{{ theme.name }}</div>
</div>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" class="h-5 w-5" @click.stop="startRename(theme)">
<Pencil class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click.stop="handleDuplicateTheme(theme)">
<Copy class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click.stop="handleDeleteTheme(theme.id)">
<Trash2 class="h-3 w-3" />
</Button>
</div>
</div>
</div>
<Button variant="outline" size="sm" class="w-full gap-1" @click="handleAddTheme">
<Plus class="h-4 w-4" />
{{ t("settings.customThemeNewTheme") }}
</Button>
</div>
<!-- Edit area -->
<div class="flex-1 min-w-0 overflow-hidden flex flex-col">
<Tabs defaultValue="visual" class="w-full flex-1 flex flex-col">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="visual">{{ t("settings.customThemeVisualEdit") }}</TabsTrigger>
<TabsTrigger value="json">{{ t("settings.customThemeJsonConfig") }}</TabsTrigger>
</TabsList>
<TabsContent value="visual" class="space-y-4 flex-1 overflow-y-auto pr-1">
<!-- Preview area -->
<div class="rounded-lg border bg-black/50 p-5 font-mono text-base">
<div class="mb-2 text-sm text-muted-foreground">{{ t("settings.customThemeLivePreview") }}</div>
<div class="leading-relaxed text-lg">
<span v-for="(token, i) in previewCode" :key="i" :style="{ color: token.color }" class="inline">
{{ token.text }}<sup v-if="token.num" class="text-xl opacity-60">{{ token.num }}</sup>
</span>
</div>
<div class="mt-2 text-lg" :style="{ color: localColors.comment }">
<sup class="text-xl">⑥</sup> -- {{ t("settings.customThemePreviewExample") }}
</div>
</div>
<!-- Preset color schemes -->
<div class="flex items-center gap-2 rounded-lg border p-3 bg-muted/30">
<Palette class="h-4 w-4 text-muted-foreground shrink-0" />
<span class="text-sm text-muted-foreground shrink-0">{{ t("settings.customThemePreset") }}:</span>
<Select v-model="selectedPreset" class="flex-1">
<SelectTrigger class="h-8 text-sm">
<SelectValue :placeholder="t('settings.customThemeSelectPreset')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="preset in presetThemes" :key="preset.name" :value="preset.name">
{{ preset.name }}
</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" class="h-8 shrink-0" @click="applyPreset">
<Copy class="mr-1 h-3 w-3" />
{{ t("settings.customThemeApply") }}
</Button>
</div>
<!-- Color configuration list -->
<div class="grid grid-cols-2 gap-3">
<div
v-for="item in colorItems"
:key="item.key"
class="relative flex items-center gap-3 rounded-lg border p-3"
>
<span class="text-xl font-bold w-8 text-center shrink-0">{{ item.num }}</span>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm">{{ item.label }}</div>
<div class="text-xs text-muted-foreground truncate">{{ item.example }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Color square + dropdown arrow -->
<div class="relative">
<button
type="button"
class="flex items-center gap-0.5 rounded border p-0.5 hover:bg-muted transition-colors"
@click.stop="togglePalette(item.key)"
>
<div class="h-6 w-6 rounded-sm" :style="{ backgroundColor: localColors[item.key] }" />
<ChevronDown class="h-3 w-3 text-muted-foreground pointer-events-none" />
</button>
<!-- Palette popup -->
<div
v-if="expandedPalette === item.key"
class="absolute right-0 top-full z-50 mt-1 rounded-lg border bg-popover p-2 shadow-lg"
@click.stop
>
<div class="space-y-1">
<div v-for="(row, rowIndex) in basicColors" :key="rowIndex" class="flex gap-1">
<button
v-for="color in row"
:key="color"
type="button"
class="h-5 w-5 rounded-sm border border-border/50 hover:scale-110 transition-transform"
:style="{ backgroundColor: color }"
@click="applyBasicColor(item.key, color)"
/>
</div>
</div>
<div class="mt-2 pt-2 border-t flex items-center gap-2">
<input
type="color"
:value="localColors[item.key]"
@input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)"
class="h-6 w-6 cursor-pointer rounded border-0 p-0"
/>
<input
type="text"
:value="localColors[item.key]"
@input="handleColorChange(item.key, ($event.target as HTMLInputElement).value)"
class="w-20 rounded border px-2 py-0.5 text-xs font-mono"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="json" class="space-y-4 flex-1 flex flex-col min-h-[400px]">
<textarea
v-model="jsonText"
@blur="handleJsonChange"
class="flex-1 w-full rounded-lg border bg-black/50 p-4 font-mono text-sm min-h-[360px]"
spellcheck="false"
/>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="handleImport">{{
t("settings.customThemePasteImport")
}}</Button>
<Button variant="outline" size="sm" @click="handleExport">{{
t("settings.customThemeExportJson")
}}</Button>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<DialogFooter class="gap-2">
<Button variant="outline" @click="emit('update:open', false)">{{ t("settings.cancel") }}</Button>
<Button @click="handleSave">{{ t("settings.customThemeSaveAndApply") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>