Feature:新增多套应用配色主题 (#2559)

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg 2026-07-05 00:34:47 +08:00 committed by GitHub
parent 896580f1c5
commit 5c35755219
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1884 additions and 99 deletions

View File

@ -122,7 +122,7 @@ const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
const connectionDialogInitialTab = ref<ConfigTab | undefined>(undefined);
const settingsPageTabOpen = ref(false);
const settingsPageActive = ref(false);
const settingsInitialTab = ref("editor");
const settingsInitialTab = ref("appearance");
const settingsInitialSection = ref<string | undefined>(undefined);
const showQueryEditorDdlDialog = ref(false);
const driverStoreTabOpen = ref(false);
@ -265,7 +265,7 @@ const appVersion = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
function openSettings(initialTab = "editor", initialSection?: string) {
function openSettings(initialTab = "appearance", initialSection?: string) {
settingsInitialTab.value = initialTab;
settingsInitialSection.value = initialSection;
if (!settingsPageActive.value) {

View File

@ -15,7 +15,7 @@ import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRig
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const props = defineProps<{
deploySql: string;
@ -120,7 +120,7 @@ async function initEditor() {
const fontSize = settingsStore.editorSettings.fontSize;
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
const themeExt = await loadEditorTheme(editorTheme, appAppearance, undefined, themePalette.value);
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres");

View File

@ -81,7 +81,7 @@ import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import AppLogo from "@/components/icons/AppLogo.vue";
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { APP_THEME_PALETTES, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { currentLocale, setLocale, type Locale } from "@/i18n";
@ -95,7 +95,23 @@ const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const { isDark, themeMode, setThemeMode } = useTheme();
const { isDark, themeMode, themePalette, setThemeMode, setThemePalette } = useTheme();
const appThemePaletteOptions = computed(
(): Array<{ value: AppThemePalette; label: string; previewColor: string }> =>
APP_THEME_PALETTES.map((palette) => ({
value: palette.value,
label: t(palette.labelKey),
previewColor: palette.previewColor,
})),
);
const selectedThemePaletteOption = computed(() => appThemePaletteOptions.value.find((option) => option.value === themePalette.value) ?? appThemePaletteOptions.value[0]);
const selectedLocaleOption = computed(() => LOCALE_OPTIONS.find((locale) => locale.value === currentLocale()) ?? LOCALE_OPTIONS[0]);
const appThemeModeOptions = computed(() => [
{ value: "light" as AppThemeMode, label: t("toolbar.themeLight"), icon: Sun },
{ value: "dark" as AppThemeMode, label: t("toolbar.themeDark"), icon: Moon },
{ value: "system" as AppThemeMode, label: t("toolbar.themeSystem"), icon: SunMoon },
]);
let cachedSystemFonts: string[] | null = null;
let pendingSystemFonts: Promise<string[]> | null = null;
@ -274,7 +290,9 @@ const systemFontsLoaded = ref(false);
const uiScaleOptions = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
const fontSearchTriggerClass =
"h-8 w-full max-w-none justify-between gap-1.5 rounded-[6px] border border-input bg-transparent py-2 pl-2.5 pr-2 text-sm font-normal shadow-none hover:bg-transparent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-transparent dark:bg-input/30 dark:hover:bg-input/50";
const appearanceFontSearchTriggerClass = `${fontSearchTriggerClass} gap-0 pl-2 pr-1.5`;
const fontSearchTriggerIconClass = "size-4 text-muted-foreground";
const appearanceFontSearchTriggerIconClass = "size-2.5 text-muted-foreground";
const disconnectTabHandlingModeDescriptionKey = computed(() => {
switch (editDisconnectTabHandlingMode.value) {
case "close-tabs":
@ -1123,14 +1141,14 @@ function setSidebarActivation(value: "single" | "double") {
editSidebarActivation.value = value;
}
const activeSettingsTab = ref("editor");
const activeSettingsTab = ref("appearance");
const isWeb = !isTauriRuntime();
const displayedAppVersion = computed(() => (props.appVersion ? `v${props.appVersion}` : ""));
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "editor", label: t("settings.editorTab") },
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
@ -1148,7 +1166,7 @@ function hasSettingsApplyFooter(value: SettingsCategory): boolean {
}
function settingsCategoryButton(value: SettingsCategory): string {
return ["w-full rounded-md px-3 py-2 text-left text-sm transition-colors", value === activeSettingsTab.value ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:bg-muted hover:text-foreground"].join(" ");
return ["w-auto shrink-0 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm transition-colors lg:w-full", value === activeSettingsTab.value ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:bg-muted hover:text-foreground"].join(" ");
}
function openExternalUrl(url: string) {
@ -1477,7 +1495,7 @@ watch(
() => settingsVisible.value,
async (open) => {
if (open) {
activeSettingsTab.value = props.initialTab || "editor";
activeSettingsTab.value = props.initialTab || "appearance";
passwordMessage.value = "";
oldPassword.value = "";
newPassword.value = "";
@ -1962,12 +1980,14 @@ const previewSettings = computed<{
fontSize: number;
theme: EditorTheme;
appAppearance: AppThemeAppearance;
appPalette: AppThemePalette;
customColors?: CustomThemeColors;
}>(() => ({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
theme: editTheme.value,
appAppearance: isDark.value ? "dark" : "light",
appPalette: themePalette.value,
customColors: getPreviewCustomThemeColors(),
}));
@ -2017,7 +2037,7 @@ watch(
async ([ss]) => {
if (!previewView.value || !fontThemeComp || !themeComp || !editorViewModule) return;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors);
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette);
previewView.value.dispatch({
effects: [themeComp.reconfigure(themeExt), fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily))],
});
@ -2065,7 +2085,7 @@ watch(previewRef, async (el) => {
previewSqlDiagnostics = previewDiagnosticsForSql(currentPreviewSql());
const ss = previewSettings.value;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors);
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette);
const diagnosticTheme = EditorView.baseTheme({
".cm-settings-preview-sql-error": {
textDecoration: "underline wavy var(--destructive)",
@ -2125,8 +2145,8 @@ onUnmounted(cleanupPreviewEditor);
</component>
</DialogHeader>
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden sm:flex-row">
<nav class="settingsCategoryNav flex min-h-0 shrink-0 gap-1 overflow-x-auto border-b pb-3 sm:w-40 sm:flex-col sm:overflow-x-hidden sm:overflow-y-auto sm:border-b-0 sm:border-r sm:pb-0 sm:pr-3">
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden lg:flex-row">
<nav class="settingsCategoryNav flex min-h-0 shrink-0 gap-1 overflow-x-auto border-b pb-3 lg:w-40 lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3">
<button v-for="category in settingsCategoryNav" :key="category.value" type="button" :class="settingsCategoryButton(category.value)" @click="activeSettingsTab = category.value">
{{ category.label }}
</button>
@ -2374,16 +2394,25 @@ onUnmounted(cleanupPreviewEditor);
</section>
<section v-else-if="activeSettingsTab === 'appearance'" class="flex flex-col gap-5 py-2">
<div class="grid gap-4 md:grid-cols-[minmax(220px,280px)_minmax(260px,1fr)]">
<div class="grid gap-x-1.5 gap-y-4 sm:grid-cols-[minmax(0,127fr)_minmax(0,127fr)_minmax(0,191fr)_minmax(0,130fr)]">
<div class="space-y-2 min-w-0">
<Label>{{ t("settings.languageTitle") }}</Label>
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.languageTitle") }}</Label>
</div>
<Select :model-value="currentLocale()" @update:model-value="onLocaleChange">
<SelectTrigger class="h-8 w-full">
<SelectValue />
<SelectTrigger class="h-8 w-full gap-0.5 px-0.5">
<SelectValue>
<span v-if="selectedLocaleOption" class="flex min-w-0 items-center gap-0.5">
<span class="inline-flex h-5 shrink-0 items-center justify-center text-sm font-medium leading-none">
{{ selectedLocaleOption.flag }}
</span>
<span class="truncate">{{ selectedLocaleOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent class="w-[150px]">
<SelectItem v-for="locale in LOCALE_OPTIONS" :key="locale.value" :value="locale.value">
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none">
{{ locale.flag }}
</span>
@ -2395,7 +2424,36 @@ onUnmounted(cleanupPreviewEditor);
</div>
<div class="space-y-2 min-w-0">
<Label>{{ t("settings.uiFontFamily") }}</Label>
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.colorTheme") }}</Label>
</div>
<Select :model-value="themePalette" @update:model-value="(value) => setThemePalette(value as AppThemePalette)">
<SelectTrigger class="h-8 w-full gap-1">
<SelectValue :placeholder="t('settings.selectColorTheme')">
<span v-if="selectedThemePaletteOption" class="flex min-w-0 items-center gap-1">
<span class="h-3 w-3 shrink-0 rounded-full border border-border shadow-xs" :style="{ background: selectedThemePaletteOption.previewColor }" />
<span class="truncate">{{ selectedThemePaletteOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in appThemePaletteOptions" :key="option.value" :value="option.value">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full border border-border shadow-xs" :style="{ background: option.previewColor }" />
{{ option.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2 min-w-0">
<div class="flex h-9 items-end gap-1">
<Label class="min-w-0 whitespace-normal leading-tight">{{ t("settings.uiFontFamily") }}</Label>
<HelpTooltip :label="t('settings.uiFontFamily')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
<p>{{ t("settings.uiFontFamilyDescription") }}</p>
</HelpTooltip>
</div>
<SearchableSelect
:model-value="editUiFontFamily"
:options="uiFontOptions"
@ -2406,8 +2464,8 @@ onUnmounted(cleanupPreviewEditor);
allow-custom
:display-name="displayUiFontFamily"
:normalize-custom="normalizeCustomFontFamilyInput"
:trigger-class="fontSearchTriggerClass"
:trigger-icon-class="fontSearchTriggerIconClass"
:trigger-class="appearanceFontSearchTriggerClass"
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
@update:model-value="onUiFontFamilyChange"
@update:open="(open: boolean) => open && loadSystemFontOptions()"
@ -2426,26 +2484,46 @@ onUnmounted(cleanupPreviewEditor);
</span>
</template>
</SearchableSelect>
<p class="text-xs text-muted-foreground">{{ t("settings.uiFontFamilyDescription") }}</p>
</div>
<div class="space-y-2 min-w-0">
<div class="flex h-9 items-end gap-1">
<Label class="min-w-0 whitespace-normal leading-tight">{{ t("settings.uiScale") }}</Label>
<HelpTooltip :label="t('settings.uiScale')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
<p>{{ t("settings.uiScaleDescription") }}</p>
</HelpTooltip>
</div>
<Select
:model-value="String(editUiScale)"
@update:model-value="
(value: any) => {
const next = Number(value);
if (Number.isFinite(next)) editUiScale = next;
}
"
>
<SelectTrigger class="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="scale in uiScaleOptions" :key="scale" :value="String(scale)" class="pl-2.5"> {{ Math.round(scale * 100) }}% </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("settings.theme") }}</Label>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<Button
v-for="option in [
{ value: 'light', label: t('toolbar.themeLight'), icon: Sun },
{ value: 'dark', label: t('toolbar.themeDark'), icon: Moon },
{ value: 'system', label: t('toolbar.themeSystem'), icon: SunMoon },
]"
v-for="option in appThemeModeOptions"
:key="option.value"
type="button"
variant="outline"
size="sm"
class="h-auto gap-1.5 px-3 py-1.5"
:class="themeMode === option.value ? 'border-blue-300 ring-2 ring-blue-300/50' : ''"
@click="setThemeMode(option.value as 'light' | 'dark' | 'system')"
class="h-8 gap-1.5 rounded-[6px] px-3"
:class="themeMode === option.value ? 'border-primary/40 bg-primary/10 text-primary ring-1 ring-primary/30' : 'text-foreground'"
@click="setThemeMode(option.value)"
>
<component :is="option.icon" class="h-3.5 w-3.5" />
{{ option.label }}
@ -2455,29 +2533,6 @@ onUnmounted(cleanupPreviewEditor);
<Separator />
<div class="space-y-2">
<Label>{{ t("settings.uiScale") }}</Label>
<Select
:model-value="String(editUiScale)"
@update:model-value="
(value: any) => {
const next = Number(value);
if (Number.isFinite(next)) editUiScale = next;
}
"
>
<SelectTrigger class="min-w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="scale in uiScaleOptions" :key="scale" :value="String(scale)" class="pl-2.5"> {{ Math.round(scale * 100) }}% </SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ t("settings.uiScaleDescription") }}</p>
</div>
<Separator />
<div class="space-y-2">
<Label>{{ t("settings.appLayout") }}</Label>
<div class="grid grid-cols-2 gap-2">

View File

@ -109,7 +109,7 @@ let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = prop
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const { t } = useI18n();
const { toast } = useToast();
@ -2295,7 +2295,7 @@ onMounted(async () => {
const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect);
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors());
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
if (initialSettings.vimModeEnabled) {
await ensureCodeMirrorVim();
}
@ -2643,7 +2643,7 @@ onMounted(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);
const themeExt = await loadEditorTheme(settings.theme, editorThemeAppearance(), themeColors, themePalette.value);
view.value.dispatch({
effects: [codeMirrorTheme.reconfigure(themeExt)],
});
@ -2724,7 +2724,7 @@ function getCurrentCustomThemeColors() {
// Reactively apply editor settings changes
watch(
[queryEditorAppearanceSettings, () => isDark.value],
[queryEditorAppearanceSettings, () => isDark.value, () => themePalette.value],
async ([ss]) => {
if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) {
return;
@ -2734,7 +2734,7 @@ watch(
}
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
const themeColors = getCurrentCustomThemeColors();
const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]);
const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]);
if (!view.value || !codeMirrorTheme || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) {
return;
}

View File

@ -169,7 +169,7 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const binaryCellDownloadMenuItems = computed(() =>
@ -4655,6 +4655,7 @@ let dialogJsonPreviewEditor: UseCellDetailEditorReturn | null = null;
const editorThemeAccessor = () => settingsStore.editorSettings.theme;
const editorAppAppearance = () => (isDark.value ? "dark" : "light") as import("@/lib/app/appTheme").AppThemeAppearance;
const editorAppPalette = () => themePalette.value;
const editorFontSize = () => settingsStore.editorSettings.fontSize;
const editorFontFamily = () => settingsStore.editorSettings.fontFamily;
const SIDE_DETAIL_EDITOR_MIN_HEIGHT = 160;
@ -4681,6 +4682,7 @@ watch(detailsEditorContainer, async (el) => {
onEscape: () => cancelDetailEdit(),
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
@ -4701,6 +4703,7 @@ watch(valueEditorContainer, async (el) => {
onBlur: () => commitValueEditorEdit(),
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
@ -4718,6 +4721,7 @@ watch(sideJsonPreviewContainer, async (el) => {
readOnly: true,
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
@ -4735,6 +4739,7 @@ watch(dialogJsonPreviewContainer, async (el) => {
readOnly: true,
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
appPalette: editorAppPalette,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
});
@ -5180,7 +5185,7 @@ const canvasSurfaceWidth = computed(() => {
if (vw <= 0) return total;
return Math.min(vw, total);
});
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${settingsStore.editorSettings.fontFamily}:${tableFontSize.value}`);
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${settingsStore.editorSettings.fontFamily}:${tableFontSize.value}`);
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
let canvasResizeObserver: ResizeObserver | null = null;

View File

@ -148,9 +148,9 @@ defineExpose({ focusSearch });
</script>
<template>
<div class="h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }">
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
<div class="app-sidebar-toolbar flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span class="flex-1 self-stretch" data-tauri-drag-region />
<template v-if="showConnectionMultiSelectToolbar">

View File

@ -524,7 +524,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
</script>
<template>
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="relative flex w-full min-w-0 shrink-0 overflow-hidden border-b" :class="[isClassicLayout ? 'bg-muted' : 'bg-background', hasFixedTabs ? 'flex-col' : '']">
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden border-b" :class="[isClassicLayout ? 'bg-muted' : 'bg-background', hasFixedTabs ? 'flex-col' : '']">
<div class="flex w-full min-w-0 shrink-0 overflow-hidden" :class="isClassicLayout ? 'h-9 items-stretch' : 'h-10 items-center px-2'">
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div v-if="showRegularTabScrollbar" class="app-tab-scrollbar" :class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }" @pointerdown="startScrollbarDrag">

View File

@ -10,7 +10,7 @@ import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue
import { shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
import { useToast } from "@/composables/useToast";
import { useSettingsStore } from "@/stores/settingsStore";
import type { AppThemeMode } from "@/lib/app/appTheme";
import { isSystemAppThemeMode, type AppThemeMode } from "@/lib/app/appTheme";
const GithubIcon = {
render() {
@ -61,14 +61,15 @@ const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
const themeTriggerIcon = computed(() => {
if (props.themeMode === "system") return SunMoon;
if (isSystemAppThemeMode(props.themeMode)) return SunMoon;
return props.isDark ? Moon : Sun;
});
const themeCycle: AppThemeMode[] = ["light", "dark", "system"];
function nextThemeMode(mode: AppThemeMode): AppThemeMode {
if (mode === "light") return "dark";
if (mode === "dark") return "system";
return "light";
const index = themeCycle.indexOf(mode);
return themeCycle[(index + 1) % themeCycle.length] ?? themeCycle[0];
}
function themeModeLabel(mode: AppThemeMode): string {
@ -362,7 +363,7 @@ const toolbarDropdownTriggerClass = `inline-flex h-8 items-center gap-1 rounded-
</script>
<template>
<div ref="toolbarEl" class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0 overflow-hidden" :class="{ 'pl-17.5': shouldReserveMacTrafficLightInset(isMac, isFullscreen, isDesktop) }" data-tauri-drag-region @dblclick="onToolbarDblClick">
<div ref="toolbarEl" class="app-toolbar h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0 overflow-hidden" :class="{ 'pl-17.5': shouldReserveMacTrafficLightInset(isMac, isFullscreen, isDesktop) }" data-tauri-drag-region @dblclick="onToolbarDblClick">
<Button variant="ghost" size="sm" :class="toolbarTextButtonClass" @click="emit('new-connection')">
<DatabaseZap class="h-3.5 w-3.5" />
<span :class="toolbarTextLabelClass">{{ t("toolbar.newConnection") }}</span>

View File

@ -145,7 +145,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
</script>
<template>
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10" :style="toolbarStyle">
<div class="app-editor-toolbar h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10" :style="toolbarStyle">
<div class="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>

View File

@ -41,7 +41,7 @@ const { toast } = useToast();
const { t } = useI18n();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const activeTab = ref<AdminTab>("configs");
const connectionInfo = ref<NacosConnectionInfo | null>(null);
const connectionError = ref("");
@ -191,7 +191,7 @@ async function mountConfigEditor() {
configLanguageExtension(configType.value),
]);
const editorSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(editorSettings.theme, editorThemeAppearance(), currentCustomThemeColors());
const theme = await loadEditorTheme(editorSettings.theme, editorThemeAppearance(), currentCustomThemeColors(), themePalette.value);
const view = new EditorView({
parent: configEditorHost.value,
state: EditorState.create({
@ -819,11 +819,11 @@ watch(historyCompareOpen, (value) => {
});
watch(
[() => settingsStore.editorSettings, () => isDark.value],
[() => settingsStore.editorSettings, () => isDark.value, () => themePalette.value],
async ([settings]) => {
const view = configEditorView.value;
if (!view) return;
const [{ EditorView }, theme] = await Promise.all([import("@codemirror/view"), loadEditorTheme(settings.theme, editorThemeAppearance(), currentCustomThemeColors())]);
const [{ EditorView }, theme] = await Promise.all([import("@codemirror/view"), loadEditorTheme(settings.theme, editorThemeAppearance(), currentCustomThemeColors(), themePalette.value)]);
if (configEditorView.value !== view) return;
view.dispatch({
effects: [configEditorTheme.reconfigure(theme), configEditorFontTheme.reconfigure(editorFontTheme(EditorView, settings.fontSize, settings.fontFamily, { fixedHeight: true, scrollable: true }))],

View File

@ -36,7 +36,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const settingsStore = useSettingsStore();
const ddlContent = ref("");
@ -84,7 +84,7 @@ async function initDdlEditor(content: string) {
const appAppearance = isDark.value ? "dark" : "light";
const fontSize = settingsStore.editorSettings.fontSize;
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
const themeExt = await loadEditorTheme(editorTheme, appAppearance, undefined, themePalette.value);
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect);
const state = EditorState.create({

View File

@ -991,7 +991,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<template>
<div ref="rootRef" class="h-full min-h-0 flex flex-col text-sm select-none" @pointerenter="pointerInsideTree = true" @pointerleave="pointerInsideTree = false">
<div class="sticky top-0 z-10 bg-background px-2 py-1">
<div class="connection-tree-search sticky top-0 z-10 bg-background px-2 py-1">
<div class="relative flex items-center gap-1">
<div class="relative flex-1">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />

View File

@ -14,7 +14,7 @@ import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestu
import i18n from "@/i18n";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import type { EditorTheme } from "@/stores/settingsStore";
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import type { AppThemeAppearance, AppThemePalette } from "@/lib/app/appTheme";
export interface UseCellDetailEditorOptions {
onChange?: (value: string) => void;
@ -24,6 +24,7 @@ export interface UseCellDetailEditorOptions {
readOnly?: boolean;
editorTheme: () => EditorTheme;
appAppearance: () => AppThemeAppearance;
appPalette: () => AppThemePalette;
fontSize: () => number;
fontFamily: () => string;
}
@ -130,14 +131,14 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
zoomCommitScheduler.flush(liveFontSize);
}
watch([() => options.fontSize(), () => options.fontFamily(), () => options.editorTheme(), () => options.appAppearance()], async ([fontSize, fontFamily, editorTheme, appearance]) => {
watch([() => options.fontSize(), () => options.fontFamily(), () => options.editorTheme(), () => options.appAppearance(), () => options.appPalette()], async ([fontSize, fontFamily, editorTheme, appearance, palette]) => {
const editor = view.value;
if (!editor || destroyed) return;
if (!isGestureZooming && !zoomCommitScheduler.hasPendingCommit()) {
liveFontSize = clampEditorFontSize(fontSize);
}
syncEditorFontCssVars(liveFontSize, fontFamily);
const theme = await loadEditorTheme(editorTheme, appearance);
const theme = await loadEditorTheme(editorTheme, appearance, undefined, palette);
if (!view.value || destroyed) return;
view.value.dispatch({
effects: [themeComp.reconfigure(theme), fontThemeComp.reconfigure(editorFontTheme(EditorView, liveFontSize, fontFamily, { fixedHeight: true, scrollable: true }))],
@ -150,7 +151,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
const doc = initialValue ?? "";
currentIsJson = options.language === "json" || shouldUseJsonMode(columnType, doc);
const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance());
const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance(), undefined, options.appPalette());
liveFontSize = clampEditorFontSize(options.fontSize());
const fontTheme = editorFontTheme(EditorView, liveFontSize, options.fontFamily(), { fixedHeight: true, scrollable: true });
const shortcuts = settingsStore.editorSettings.shortcuts;

View File

@ -1,9 +1,25 @@
import { computed, ref } from "vue";
import { APP_THEME_STORAGE_KEY, getTauriThemeForMode, normalizeAppThemeMode, resolveAppThemeAppearance, type AppThemeMode } from "@/lib/app/appTheme";
import {
APP_THEME_PALETTE_CLASS_NAMES,
APP_THEME_PALETTE_STORAGE_KEY,
APP_THEME_STORAGE_KEY,
getAppThemePaletteClass,
getTauriThemeForMode,
isSystemAppThemeMode,
normalizeAppThemeMode,
normalizeAppThemePalette,
resolveAppThemeAppearance,
type AppThemeMode,
type AppThemePalette,
} from "@/lib/app/appTheme";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
const themeMode = ref<AppThemeMode>(normalizeAppThemeMode(safeLocalStorageGet(APP_THEME_STORAGE_KEY)));
const savedThemeMode = safeLocalStorageGet(APP_THEME_STORAGE_KEY);
const themeMode = ref<AppThemeMode>(normalizeAppThemeMode(savedThemeMode));
const savedThemePalette = safeLocalStorageGet(APP_THEME_PALETTE_STORAGE_KEY);
const themePalette = ref<AppThemePalette>(normalizeAppThemePalette(savedThemePalette));
if (savedThemeMode && savedThemeMode !== themeMode.value) safeLocalStorageSet(APP_THEME_STORAGE_KEY, themeMode.value);
const systemPrefersDark = ref(readSystemPrefersDark());
const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemPrefersDark.value) === "dark");
@ -22,7 +38,7 @@ function setupSystemThemeListener() {
systemPrefersDark.value = mediaQuery.matches;
const onChange = (event: MediaQueryListEvent) => {
systemPrefersDark.value = event.matches;
if (themeMode.value === "system") applyTheme();
if (isSystemAppThemeMode(themeMode.value)) applyTheme();
};
mediaQuery.addEventListener("change", onChange);
isListeningForSystemTheme = true;
@ -36,6 +52,9 @@ function applyTheme() {
doc.classList.add("disable-transitions");
doc.classList.toggle("dark", dark);
for (const className of APP_THEME_PALETTE_CLASS_NAMES) doc.classList.remove(className);
const paletteClass = getAppThemePaletteClass(themePalette.value);
if (paletteClass) doc.classList.add(paletteClass);
doc.style.colorScheme = dark ? "dark" : "light";
// force reflow so the class toggle takes effect before re-enabling transitions
@ -65,6 +84,12 @@ function setThemeMode(mode: AppThemeMode) {
applyTheme();
}
function setThemePalette(palette: AppThemePalette) {
themePalette.value = palette;
safeLocalStorageSet(APP_THEME_PALETTE_STORAGE_KEY, palette);
applyTheme();
}
export function useTheme() {
setupSystemThemeListener();
@ -72,5 +97,5 @@ export function useTheme() {
setThemeMode(isDark.value ? "light" : "dark");
}
return { isDark, themeMode, applyTheme, setThemeMode, toggleTheme };
return { isDark, themeMode, themePalette, applyTheme, setThemeMode, setThemePalette, toggleTheme };
}

View File

@ -2681,6 +2681,21 @@ export default {
uiScaleDescription: "Scale the entire desktop UI for high-DPI displays. Changes apply immediately and are restored on next launch.",
theme: "Theme",
selectTheme: "Select theme...",
colorTheme: "Color Theme",
selectColorTheme: "Select color theme...",
themePalettePearl: "White",
themePaletteMist: "Mist Gray",
themePaletteGraphite: "Titanium Gray",
themePaletteCobalt: "Cobalt Blue",
themePaletteSage: "Sage Green",
themePaletteAmber: "Warm Yellow",
themePaletteBlush: "Sakura Pink",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "Follow app theme",
appLayout: "Interface Layout",
appLayoutSeparated: "Modern separated",

View File

@ -2637,6 +2637,21 @@ export default withEnglishFallback({
uiScaleDescription: "Escala toda la interfaz de escritorio para pantallas de alta densidad. Los cambios se aplican al instante y se restauran al volver a abrir.",
theme: "Tema",
selectTheme: "Seleccionar tema...",
colorTheme: "Tema de color",
selectColorTheme: "Seleccionar tema de color...",
themePalettePearl: "Blanco",
themePaletteMist: "Gris niebla",
themePaletteGraphite: "Gris titanio",
themePaletteCobalt: "Azul cobalto",
themePaletteSage: "Verde salvia",
themePaletteAmber: "Amarillo cálido",
themePaletteBlush: "Rosa sakura",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "Seguir el tema de la aplicación",
appLayout: "Diseño de la interfaz",
appLayoutSeparated: "Moderno separado",

View File

@ -2635,6 +2635,21 @@ export default withEnglishFallback({
uiScaleDescription: "Scala l'intera interfaccia utente desktop per display ad alta densità (High-DPI). Le modifiche si applicano immediatamente e vengono ripristinate al prossimo avvio.",
theme: "Tema",
selectTheme: "Seleziona tema...",
colorTheme: "Tema colore",
selectColorTheme: "Seleziona tema colore...",
themePalettePearl: "Bianco",
themePaletteMist: "Grigio nebbia",
themePaletteGraphite: "Grigio titanio",
themePaletteCobalt: "Blu cobalto",
themePaletteSage: "Verde salvia",
themePaletteAmber: "Giallo caldo",
themePaletteBlush: "Rosa sakura",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "Segui il tema dell'applicazione",
appLayout: "Layout dell'Interfaccia",
appLayoutSeparated: "Separato moderno",

View File

@ -2635,6 +2635,21 @@ export default withEnglishFallback({
uiScaleDescription: "高DPIディスプレイ向けにデスクトップUI全体をスケーリングします。変更は即座に適用され、次回起動時に復元されます。",
theme: "テーマ",
selectTheme: "テーマを選択...",
colorTheme: "配色テーマ",
selectColorTheme: "配色テーマを選択...",
themePalettePearl: "白",
themePaletteMist: "霧灰",
themePaletteGraphite: "チタングレー",
themePaletteCobalt: "コバルトブルー",
themePaletteSage: "セージグリーン",
themePaletteAmber: "暖黄",
themePaletteBlush: "桜ピンク",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "アプリテーマに従う",
appLayout: "インターフェースレイアウト",
appLayoutSeparated: "モダン分離型",

View File

@ -2636,6 +2636,21 @@ export default withEnglishFallback({
uiScaleDescription: "Dimensione toda a UI do desktop para telas de alta resolução (high-DPI). As alterações são aplicadas imediatamente e restauradas na próxima inicialização.",
theme: "Tema",
selectTheme: "Selecionar tema...",
colorTheme: "Tema de cores",
selectColorTheme: "Selecionar tema de cores...",
themePalettePearl: "Branco",
themePaletteMist: "Cinza névoa",
themePaletteGraphite: "Cinza titânio",
themePaletteCobalt: "Azul cobalto",
themePaletteSage: "Verde sálvia",
themePaletteAmber: "Amarelo quente",
themePaletteBlush: "Rosa sakura",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "Seguir o tema do app",
appLayout: "Layout da interface",
appLayoutSeparated: "Separado moderno",

View File

@ -2681,6 +2681,21 @@ export default withEnglishFallback({
uiScaleDescription: "按比例缩放整个桌面端界面,适合高清屏;修改后立即生效,并在下次启动时恢复。",
theme: "主题",
selectTheme: "选择主题...",
colorTheme: "配色主题",
selectColorTheme: "选择配色主题...",
themePalettePearl: "洁白",
themePaletteMist: "雾灰",
themePaletteGraphite: "钛灰",
themePaletteCobalt: "钴蓝",
themePaletteSage: "青绿",
themePaletteAmber: "暖黄",
themePaletteBlush: "樱粉",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "跟随应用主题",
appLayout: "界面布局",
appLayoutSeparated: "现代分隔",

View File

@ -2538,6 +2538,21 @@ export default withEnglishFallback({
uiScaleDescription: "按比例縮放整個桌面端介面,適合高 DPI 螢幕;修改後立即生效,並在下次啟動時復原。",
theme: "主題",
selectTheme: "選擇主題……",
colorTheme: "配色主題",
selectColorTheme: "選擇配色主題……",
themePalettePearl: "潔白",
themePaletteMist: "霧灰",
themePaletteGraphite: "鈦灰",
themePaletteCobalt: "鈷藍",
themePaletteSage: "青綠",
themePaletteAmber: "暖黃",
themePaletteBlush: "櫻粉",
themePaletteVscode: "VS Code",
themePaletteIdea: "IDEA",
themePaletteXcode: "Xcode",
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
followAppTheme: "跟隨應用程式主題",
appLayout: "介面配置",
appLayoutSeparated: "現代分隔",

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { resolveEditorTheme } from "@/lib/editor/editorThemes";
import type { AppThemePalette } from "@/lib/app/appTheme";
import type { EditorTheme } from "@/stores/settingsStore";
describe("resolveEditorTheme", () => {
it("maps only the follow-app editor theme to application IDE palettes", () => {
expect(resolveEditorTheme("app", "light", "xcode")).toBe("xcode");
expect(resolveEditorTheme("app", "dark", "xcode")).toBe("xcode-dark");
expect(resolveEditorTheme("app", "light", "cursor")).toBe("cursor-light");
expect(resolveEditorTheme("app", "dark", "cursor")).toBe("cursor-dark");
});
it("keeps explicit editor themes unchanged across application palettes", () => {
const explicitThemes: Array<Exclude<EditorTheme, "app">> = [
"one-dark",
"vscode-dark",
"vscode-light",
"nord",
"okaidia",
"material",
"duotone-light",
"duotone-dark",
"xcode",
"xcode-dark",
"idea-light",
"idea-dark",
"jetbrains-light",
"jetbrains-dark",
"cursor-light",
"cursor-dark",
"claude-light",
"claude-dark",
"custom",
];
const appPalettes: AppThemePalette[] = ["pearl", "vscode", "idea", "xcode", "jetbrains", "cursor", "claude"];
for (const theme of explicitThemes) {
for (const palette of appPalettes) {
expect(resolveEditorTheme(theme, "dark", palette)).toBe(theme);
expect(resolveEditorTheme(theme, "light", palette)).toBe(theme);
}
}
});
});

View File

@ -1,20 +1,64 @@
import type { Theme } from "@tauri-apps/api/window";
export const APP_THEME_STORAGE_KEY = "dbx-theme";
export const APP_THEME_PALETTE_STORAGE_KEY = "dbx-theme-palette";
export type AppThemeMode = "light" | "dark" | "system";
export type AppThemeAppearance = "light" | "dark";
export type AppThemePalette = "pearl" | "mist" | "graphite" | "cobalt" | "sage" | "amber" | "blush" | "vscode" | "idea" | "xcode" | "jetbrains" | "cursor" | "claude";
export type AppThemePaletteOption = {
value: AppThemePalette;
labelKey: string;
className: string | null;
previewColor: string;
};
export const APP_THEME_PALETTES: AppThemePaletteOption[] = [
{ value: "pearl", labelKey: "settings.themePalettePearl", className: null, previewColor: "#ffffff" },
{ value: "mist", labelKey: "settings.themePaletteMist", className: "theme-soft", previewColor: "#e4eaf2" },
{ value: "graphite", labelKey: "settings.themePaletteGraphite", className: "theme-graphite", previewColor: "#d8dce4" },
{ value: "cobalt", labelKey: "settings.themePaletteCobalt", className: "theme-cobalt", previewColor: "#d8e6f7" },
{ value: "sage", labelKey: "settings.themePaletteSage", className: "theme-sage", previewColor: "#dbe9e2" },
{ value: "amber", labelKey: "settings.themePaletteAmber", className: "theme-amber", previewColor: "#f4e4b8" },
{ value: "blush", labelKey: "settings.themePaletteBlush", className: "theme-blush", previewColor: "#f4d9e6" },
{ value: "vscode", labelKey: "settings.themePaletteVscode", className: "theme-vscode", previewColor: "#007acc" },
{ value: "idea", labelKey: "settings.themePaletteIdea", className: "theme-idea", previewColor: "#4b6eaf" },
{ value: "xcode", labelKey: "settings.themePaletteXcode", className: "theme-xcode", previewColor: "#0a84ff" },
{ value: "jetbrains", labelKey: "settings.themePaletteJetbrains", className: "theme-jetbrains", previewColor: "#7b61ff" },
{ value: "cursor", labelKey: "settings.themePaletteCursor", className: "theme-cursor", previewColor: "#6ba4ff" },
{ value: "claude", labelKey: "settings.themePaletteClaude", className: "theme-claude", previewColor: "#c47a50" },
];
export const APP_THEME_PALETTE_CLASS_NAMES = APP_THEME_PALETTES.map((palette) => palette.className).filter((className): className is string => Boolean(className));
export function normalizeAppThemeMode(value: string | null): AppThemeMode {
if (value === "soft-light") return "light";
if (value === "soft-dark") return "dark";
if (value === "soft-system") return "system";
if (value === "dark" || value === "light" || value === "system") return value;
return "light";
}
export function normalizeAppThemePalette(value: string | null): AppThemePalette {
if (value === "mist" || value === "graphite" || value === "cobalt" || value === "sage" || value === "amber" || value === "blush" || value === "vscode" || value === "idea" || value === "xcode" || value === "jetbrains" || value === "cursor" || value === "claude" || value === "pearl") return value;
return "pearl";
}
export function getAppThemePaletteClass(palette: AppThemePalette): string | null {
return APP_THEME_PALETTES.find((option) => option.value === palette)?.className ?? null;
}
export function isSystemAppThemeMode(mode: AppThemeMode): boolean {
return mode === "system";
}
export function resolveAppThemeAppearance(mode: AppThemeMode, systemPrefersDark: boolean): AppThemeAppearance {
if (mode === "system") return systemPrefersDark ? "dark" : "light";
return mode;
if (isSystemAppThemeMode(mode)) return systemPrefersDark ? "dark" : "light";
return mode === "dark" ? "dark" : "light";
}
export function getTauriThemeForMode(mode: AppThemeMode): Theme | null {
return mode === "system" ? null : mode;
if (isSystemAppThemeMode(mode)) return null;
return resolveAppThemeAppearance(mode, false);
}

View File

@ -1,6 +1,6 @@
import type { Extension } from "@codemirror/state";
import type { EditorTheme, CustomThemeColors } from "@/stores/settingsStore";
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import type { AppThemeAppearance, AppThemePalette } from "@/lib/app/appTheme";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
@ -54,7 +54,7 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV
// 根据系统主题设置默认背景色和前景色
const defaultColors = isDark ? { background: "#1e1e2e", foreground: "#cdd6f4" } : { background: "#fafafa", foreground: "#242424" };
const c = { ...defaultColors, ...customThemeColors, ...(colors || {}) };
const c = { ...defaultColors, ...customThemeColors, ...colors };
// 映射用户自定义属性名到 CodeMirror 内部属性名
if (colors) {
@ -179,6 +179,371 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV
return [theme, syntaxHighlighting(highlightStyle)];
}
type IdeEditorThemeColors = {
dark: boolean;
background: string;
foreground: string;
selection: string;
selectionMatch: string;
cursor: string;
gutterBackground: string;
gutterForeground: string;
gutterActiveForeground: string;
activeLine: string;
matchingBracket: string;
gutterBorder: string;
keyword: string;
string: string;
number: string;
comment: string;
type: string;
variable: string;
function: string;
operator: string;
punctuation: string;
property: string;
builtin: string;
meta: string;
invalid: string;
tag: string;
attribute: string;
className: string;
keywordBold?: boolean;
stringBold?: boolean;
numberBold?: boolean;
};
const IDE_EDITOR_THEMES = {
ideaLight: {
dark: false,
background: "#ffffff",
foreground: "#080808",
selection: "#a6d2ff",
selectionMatch: "#93d9d9",
cursor: "#000000",
gutterBackground: "#f2f2f2",
gutterForeground: "#adadad",
gutterActiveForeground: "#767a8a",
activeLine: "#fcfaed",
matchingBracket: "#93d9d9",
gutterBorder: "#d4d4d4",
keyword: "#0033b3",
string: "#067d17",
number: "#1750eb",
comment: "#8c8c8c",
type: "#0033b3",
variable: "#174ad4",
function: "#00627a",
operator: "#080808",
punctuation: "#080808",
property: "#871094",
builtin: "#0033b3",
meta: "#9e880d",
invalid: "#f50000",
tag: "#0033b3",
attribute: "#871094",
className: "#174be6",
keywordBold: true,
stringBold: true,
numberBold: true,
},
ideaDark: {
dark: true,
background: "#2b2b2b",
foreground: "#a9b7c6",
selection: "#214283",
selectionMatch: "#3b514d",
cursor: "#bbbbbb",
gutterBackground: "#313335",
gutterForeground: "#606366",
gutterActiveForeground: "#a4a3a3",
activeLine: "#323232",
matchingBracket: "#3b514d",
gutterBorder: "#4d4d4d",
keyword: "#cc7832",
string: "#6a8759",
number: "#6897bb",
comment: "#808080",
type: "#a9b7c6",
variable: "#a9b7c6",
function: "#ffc66d",
operator: "#cc7832",
punctuation: "#a9b7c6",
property: "#9876aa",
builtin: "#cc7832",
meta: "#bbb529",
invalid: "#ff0000",
tag: "#e8bf6a",
attribute: "#bababa",
className: "#a9b7c6",
},
jetbrainsLight: {
dark: false,
background: "#ffffff",
foreground: "#080808",
selection: "#a6d2ff",
selectionMatch: "#c9ecec",
cursor: "#000000",
gutterBackground: "#ffffff",
gutterForeground: "#aeb3c2",
gutterActiveForeground: "#767a8a",
activeLine: "#f5f8fe",
matchingBracket: "#93d9d9",
gutterBorder: "#ebecf0",
keyword: "#0033b3",
string: "#067d17",
number: "#1750eb",
comment: "#8c8c8c",
type: "#0033b3",
variable: "#174ad4",
function: "#00627a",
operator: "#080808",
punctuation: "#080808",
property: "#871094",
builtin: "#0033b3",
meta: "#9e880d",
invalid: "#f50000",
tag: "#0033b3",
attribute: "#871094",
className: "#174be6",
keywordBold: true,
stringBold: true,
numberBold: true,
},
jetbrainsDark: {
dark: true,
background: "#1e1f22",
foreground: "#bcbec4",
selection: "#2e436e",
selectionMatch: "#114957",
cursor: "#ced0d6",
gutterBackground: "#1e1f22",
gutterForeground: "#4b5059",
gutterActiveForeground: "#a1a3ab",
activeLine: "#26282e",
matchingBracket: "#43454a",
gutterBorder: "#313438",
keyword: "#cf8e6d",
string: "#6aab73",
number: "#2aacb8",
comment: "#7a7e85",
type: "#cf8e6d",
variable: "#bcbec4",
function: "#56a8f5",
operator: "#bcbec4",
punctuation: "#bcbec4",
property: "#c77dbb",
builtin: "#cf8e6d",
meta: "#b3ae60",
invalid: "#f75464",
tag: "#2fbaa3",
attribute: "#b3ae60",
className: "#56a8f5",
},
cursorLight: {
dark: false,
background: "#fcfcfc",
foreground: "#141414eb",
selection: "#1414141e",
selectionMatch: "#14141411",
cursor: "#141414eb",
gutterBackground: "#fcfcfc",
gutterForeground: "#1414147a",
gutterActiveForeground: "#141414ad",
activeLine: "#ededed",
matchingBracket: "#1414141e",
gutterBorder: "#14141413",
keyword: "#b3003f",
string: "#9e94d5",
number: "#b8448b",
comment: "#141414ad",
type: "#206595",
variable: "#206595",
function: "#db704b",
operator: "#b3003f",
punctuation: "#141414eb",
property: "#1f8a65",
builtin: "#206595",
meta: "#1f8a65",
invalid: "#cf2d56",
tag: "#206595",
attribute: "#6049b3",
className: "#206595",
},
cursorDark: {
dark: true,
background: "#181818",
foreground: "#e4e4e4eb",
selection: "#40404099",
selectionMatch: "#404040cc",
cursor: "#e4e4e4eb",
gutterBackground: "#181818",
gutterForeground: "#e4e4e442",
gutterActiveForeground: "#e4e4e4eb",
activeLine: "#262626",
matchingBracket: "#e4e4e41e",
gutterBorder: "#e4e4e413",
keyword: "#82d2ce",
string: "#e394dc",
number: "#ebc88d",
comment: "#e4e4e45e",
type: "#efb080",
variable: "#87c3ff",
function: "#efb080",
operator: "#d6d6dd",
punctuation: "#d6d6dd",
property: "#82d2ce",
builtin: "#a8cc7c",
meta: "#a8cc7c",
invalid: "#e34671",
tag: "#82d2ce",
attribute: "#aaa0fa",
className: "#efb080",
},
claudeLight: {
dark: false,
background: "#fffdf8",
foreground: "#302820",
selection: "#ead8c6",
selectionMatch: "#e7d8c8",
cursor: "#b86b3c",
gutterBackground: "#f8f1e9",
gutterForeground: "#9a7f66",
gutterActiveForeground: "#5f4b3a",
activeLine: "#f4e9de",
matchingBracket: "#e2c9b1",
gutterBorder: "#ddcdbb",
keyword: "#9a4f2e",
string: "#4f7d5d",
number: "#2d6f91",
comment: "#8c745f",
type: "#7a5aa8",
variable: "#3e5f75",
function: "#b86b3c",
operator: "#6b5544",
punctuation: "#6b5544",
property: "#9a4f2e",
builtin: "#4f7d5d",
meta: "#8f6b2e",
invalid: "#c3493d",
tag: "#7a5aa8",
attribute: "#b86b3c",
className: "#7a5aa8",
},
claudeDark: {
dark: true,
background: "#211f1c",
foreground: "#d8d0c4",
selection: "#44382f",
selectionMatch: "#564539",
cursor: "#d28a5f",
gutterBackground: "#1b1917",
gutterForeground: "#8f7f70",
gutterActiveForeground: "#d8d0c4",
activeLine: "#2a2723",
matchingBracket: "#564539",
gutterBorder: "#7f6c5b59",
keyword: "#d28a5f",
string: "#74b195",
number: "#65a1c6",
comment: "#a39686",
type: "#a08fcd",
variable: "#d8d0c4",
function: "#d69a6b",
operator: "#e0d0c0",
punctuation: "#c9b9a7",
property: "#d28a5f",
builtin: "#74b195",
meta: "#d4ae63",
invalid: "#e2675f",
tag: "#a08fcd",
attribute: "#d69a6b",
className: "#a08fcd",
},
} satisfies Record<string, IdeEditorThemeColors>;
function createIdeEditorTheme(EditorView: typeof import("@codemirror/view").EditorView, c: IdeEditorThemeColors): Extension {
const theme = EditorView.theme(
{
"&": {
backgroundColor: c.background,
color: c.foreground,
[EDITOR_SELECTION_BACKGROUND_CSS_VAR]: c.selection,
},
".cm-scroller": {
backgroundColor: c.background,
},
".cm-content": {
caretColor: c.cursor,
},
".cm-cursor": {
borderLeftColor: c.cursor,
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: c.selection,
},
".cm-selectionMatch": {
backgroundColor: c.selectionMatch,
},
".cm-activeLine": {
backgroundColor: c.activeLine,
},
".cm-gutters": {
backgroundColor: c.gutterBackground,
borderRight: `1px solid ${c.gutterBorder}`,
color: c.gutterForeground,
},
".cm-activeLineGutter": {
backgroundColor: c.activeLine,
color: c.gutterActiveForeground,
},
".cm-matchingBracket": {
backgroundColor: c.matchingBracket,
outline: "none",
},
},
{ dark: c.dark },
);
const highlightStyle = HighlightStyle.define([
{ tag: [tags.keyword, tags.controlKeyword, tags.definitionKeyword, tags.moduleKeyword, tags.operatorKeyword, tags.modifier, tags.bool, tags.null], color: c.keyword, ...(c.keywordBold ? { fontWeight: "bold" } : {}) },
{ tag: [tags.string, tags.special(tags.string), tags.regexp, tags.escape, tags.inserted], color: c.string, ...(c.stringBold ? { fontWeight: "bold" } : {}) },
{ tag: [tags.number, tags.integer, tags.float], color: c.number, ...(c.numberBold ? { fontWeight: "bold" } : {}) },
{ tag: [tags.comment, tags.lineComment, tags.blockComment, tags.quote], color: c.comment, fontStyle: "italic" },
{ tag: [tags.typeName, tags.typeOperator, tags.unit], color: c.type },
{ tag: [tags.name, tags.variableName, tags.definition(tags.variableName)], color: c.variable },
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.function(tags.name), tags.macroName], color: c.function },
{ tag: [tags.standard(tags.variableName), tags.special(tags.name)], color: c.builtin },
{ tag: [tags.propertyName, tags.labelName, tags.annotation], color: c.property },
{ tag: [tags.operator, tags.compareOperator, tags.logicOperator, tags.arithmeticOperator, tags.derefOperator], color: c.operator },
{ tag: [tags.punctuation, tags.separator, tags.paren, tags.brace, tags.bracket, tags.angleBracket], color: c.punctuation },
{ tag: tags.tagName, color: c.tag },
{ tag: tags.attributeName, color: c.attribute },
{ tag: tags.attributeValue, color: c.string },
{ tag: [tags.className, tags.namespace], color: c.className },
{ tag: [tags.meta, tags.processingInstruction], color: c.meta },
{ tag: tags.invalid, color: c.invalid },
{ tag: [tags.heading, tags.heading1, tags.heading2, tags.heading3], color: c.keyword, fontWeight: "bold" },
{ tag: tags.strong, color: c.foreground, fontWeight: "bold" },
{ tag: tags.emphasis, color: c.foreground, fontStyle: "italic" },
{ tag: [tags.link, tags.url], color: c.type, textDecoration: "underline" },
{ tag: tags.literal, color: c.string },
{ tag: tags.deleted, color: c.invalid },
{ tag: tags.changed, color: c.property },
{ tag: tags.self, color: c.keyword },
{ tag: tags.list, color: c.foreground },
{ tag: tags.monospace, color: c.foreground },
{ tag: tags.strikethrough, color: c.invalid, textDecoration: "line-through" },
{ tag: tags.contentSeparator, color: c.operator },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
async function loadIdeEditorTheme(colors: IdeEditorThemeColors): Promise<Extension> {
return createIdeEditorTheme((await import("@codemirror/view")).EditorView, colors);
}
// ======================================================
const TABLE_ICON: LucideIconNode = [
@ -242,15 +607,32 @@ export function cellDetailActiveLineColor(): string {
return colorMixValue("var(--accent)", "color-mix(in oklch, var(--foreground) 4%, transparent)");
}
/** Load a CodeMirror theme extension by theme name. */
export function resolveEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance): Exclude<EditorTheme, "app"> {
if (theme === "app") return appAppearance === "dark" ? "one-dark" : "vscode-light";
/** Resolve the concrete CodeMirror theme used by the "Follow app theme" setting. */
export function resolveEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance, appPalette: AppThemePalette = "pearl"): Exclude<EditorTheme, "app"> {
if (theme === "app") {
switch (appPalette) {
case "vscode":
return appAppearance === "dark" ? "vscode-dark" : "vscode-light";
case "idea":
return appAppearance === "dark" ? "idea-dark" : "idea-light";
case "xcode":
return appAppearance === "dark" ? "xcode-dark" : "xcode";
case "jetbrains":
return appAppearance === "dark" ? "jetbrains-dark" : "jetbrains-light";
case "cursor":
return appAppearance === "dark" ? "cursor-dark" : "cursor-light";
case "claude":
return appAppearance === "dark" ? "claude-dark" : "claude-light";
default:
return appAppearance === "dark" ? "one-dark" : "vscode-light";
}
}
return theme;
}
/** Load a CodeMirror theme extension by theme name. */
export async function loadEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance = "dark", customColors?: CustomThemeColors): Promise<Extension> {
const resolvedTheme = resolveEditorTheme(theme, appAppearance);
export async function loadEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance = "dark", customColors?: CustomThemeColors, appPalette: AppThemePalette = "pearl"): Promise<Extension> {
const resolvedTheme = resolveEditorTheme(theme, appAppearance, appPalette);
switch (resolvedTheme) {
case "one-dark":
return (await import("@codemirror/theme-one-dark")).oneDark;
@ -270,6 +652,24 @@ export async function loadEditorTheme(theme: EditorTheme, appAppearance: AppThem
return (await import("@uiw/codemirror-theme-duotone")).duotoneDark;
case "xcode":
return (await import("@uiw/codemirror-theme-xcode")).xcodeLight;
case "xcode-dark":
return (await import("@uiw/codemirror-theme-xcode")).xcodeDark;
case "idea-light":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.ideaLight);
case "idea-dark":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.ideaDark);
case "jetbrains-light":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.jetbrainsLight);
case "jetbrains-dark":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.jetbrainsDark);
case "cursor-light":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.cursorLight);
case "cursor-dark":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.cursorDark);
case "claude-light":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.claudeLight);
case "claude-dark":
return loadIdeEditorTheme(IDE_EDITOR_THEMES.claudeDark);
case "custom":
return createCustomTheme((await import("@codemirror/view")).EditorView, customColors, appAppearance === "dark");
default:

View File

@ -30,6 +30,12 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({}).updateDownloadSource).toBe("official");
});
it("preserves explicit editor themes from saved settings", () => {
expect(normalizeEditorSettings({ theme: "xcode" }).theme).toBe("xcode");
expect(normalizeEditorSettings({ theme: "one-dark" }).theme).toBe("one-dark");
expect(normalizeEditorSettings({ theme: "custom" }).theme).toBe("custom");
});
it("restores all open tabs on launch by default", () => {
expect(normalizeEditorSettings({}).openTabsRestoreMode).toBe("all");
});

View File

@ -252,7 +252,27 @@ function inferAiProviderFromConfig(config: Partial<AiConfig> | null | undefined)
return "claude";
}
export type EditorTheme = "app" | "one-dark" | "vscode-dark" | "vscode-light" | "nord" | "okaidia" | "material" | "duotone-light" | "duotone-dark" | "xcode" | "custom";
export type EditorTheme =
| "app"
| "one-dark"
| "vscode-dark"
| "vscode-light"
| "nord"
| "okaidia"
| "material"
| "duotone-light"
| "duotone-dark"
| "xcode"
| "xcode-dark"
| "idea-light"
| "idea-dark"
| "jetbrains-light"
| "jetbrains-dark"
| "cursor-light"
| "cursor-dark"
| "claude-light"
| "claude-dark"
| "custom";
const STRUCTURE_EDITOR_DENSITIES = ["compact", "standard", "comfortable"] as const;
export type StructureEditorDensity = (typeof STRUCTURE_EDITOR_DENSITIES)[number];
@ -426,6 +446,15 @@ export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }
{ value: "duotone-light", label: "Duotone Light", dark: false },
{ value: "duotone-dark", label: "Duotone Dark", dark: true },
{ value: "xcode", label: "Xcode", dark: false },
{ value: "xcode-dark", label: "Xcode Dark", dark: true },
{ value: "idea-light", label: "IDEA Light", dark: false },
{ value: "idea-dark", label: "IDEA Darcula", dark: true },
{ value: "jetbrains-light", label: "JetBrains Light", dark: false },
{ value: "jetbrains-dark", label: "JetBrains Dark", dark: true },
{ value: "cursor-light", label: "Cursor Light", dark: false },
{ value: "cursor-dark", label: "Cursor Dark", dark: true },
{ value: "claude-light", label: "Claude Code Light", dark: false },
{ value: "claude-dark", label: "Claude Code Dark", dark: true },
{ value: "custom", label: "Custom", dark: true },
];

File diff suppressed because it is too large Load Diff