diff --git a/src/App.vue b/src/App.vue
index 320e3da272..66d4e083d6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -49,7 +49,7 @@ const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore();
const { message: toastMessage, visible: toastVisible, toast } = useToast();
-const { isDark, applyTheme, toggleTheme } = useTheme();
+const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
const {
checkingUpdates,
updateInfo,
@@ -537,6 +537,7 @@ onUnmounted(() => {
{
:has-sql-file-connections="hasSqlFileConnections"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
- @toggle-theme="toggleTheme"
+ @set-theme-mode="setThemeMode"
@toggle-ai="toggleAiPanel"
@toggle-history="showHistory = !showHistory"
@open-github="openGitHub"
diff --git a/src/components/layout/AppToolbar.vue b/src/components/layout/AppToolbar.vue
index a0bcd24164..5acd13230b 100644
--- a/src/components/layout/AppToolbar.vue
+++ b/src/components/layout/AppToolbar.vue
@@ -7,6 +7,8 @@ import {
Globe,
Moon,
Sun,
+ Monitor,
+ Check,
History,
Bot,
ArrowLeftRight,
@@ -27,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import WindowControls from "@/components/layout/WindowControls.vue";
import { useWindowControls } from "@/composables/useWindowControls";
import { currentLocale, setLocale, type Locale } from "@/i18n";
+import type { AppThemeMode } from "@/lib/appTheme";
const localeOptions: { value: Locale; flag: string; label: string }[] = [
{ value: "en", flag: "🇺🇸", label: "English" },
@@ -36,6 +39,7 @@ const localeOptions: { value: Locale; flag: string; label: string }[] = [
defineProps<{
isDark: boolean;
+ themeMode: AppThemeMode;
showAiPanel: boolean;
showHistory: boolean;
checkingUpdates: boolean;
@@ -46,7 +50,7 @@ defineProps<{
const emit = defineEmits<{
"new-connection": [];
"new-query": [];
- "toggle-theme": [];
+ "set-theme-mode": [mode: AppThemeMode];
"toggle-ai": [];
"toggle-history": [];
"open-github": [];
@@ -178,15 +182,44 @@ function onToolbarDblClick(e: MouseEvent) {
AI
-
-
-
- {{ isDark ? "Light" : "Dark" }}
-
+
+
+
+
+ {{ t("toolbar.themeLight") }}
+
+
+
+
+ {{ t("toolbar.themeDark") }}
+
+
+
+
+ {{ t("toolbar.themeSystem") }}
+
+
+
+
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
index cf4b4ae3e6..9e0f7308a6 100644
--- a/src/composables/useTheme.ts
+++ b/src/composables/useTheme.ts
@@ -1,25 +1,63 @@
-import { ref } from "vue";
+import { computed, ref } from "vue";
+import {
+ APP_THEME_STORAGE_KEY,
+ getTauriThemeForMode,
+ normalizeAppThemeMode,
+ resolveAppThemeAppearance,
+ type AppThemeMode,
+} from "@/lib/appTheme";
import { isTauriRuntime } from "@/lib/tauriRuntime";
-import type { Theme } from "@tauri-apps/api/window";
+
+const themeMode = ref(
+ normalizeAppThemeMode(typeof localStorage === "undefined" ? null : localStorage.getItem(APP_THEME_STORAGE_KEY)),
+);
+const systemPrefersDark = ref(readSystemPrefersDark());
+const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemPrefersDark.value) === "dark");
+
+let mediaQuery: MediaQueryList | null = null;
+let isListeningForSystemTheme = false;
+
+function readSystemPrefersDark() {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+}
+
+function setupSystemThemeListener() {
+ if (isListeningForSystemTheme || typeof window === "undefined" || typeof window.matchMedia !== "function") return;
+ mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ systemPrefersDark.value = mediaQuery.matches;
+ const onChange = (event: MediaQueryListEvent) => {
+ systemPrefersDark.value = event.matches;
+ if (themeMode.value === "system") applyTheme();
+ };
+ mediaQuery.addEventListener("change", onChange);
+ isListeningForSystemTheme = true;
+}
+
+function applyTheme() {
+ if (typeof document !== "undefined") {
+ document.documentElement.classList.toggle("dark", isDark.value);
+ }
+ if (!isTauriRuntime()) return;
+ import("@tauri-apps/api/window").then(({ getCurrentWindow }) => {
+ getCurrentWindow()
+ .setTheme(getTauriThemeForMode(themeMode.value))
+ .catch(() => {});
+ });
+}
+
+function setThemeMode(mode: AppThemeMode) {
+ themeMode.value = mode;
+ localStorage.setItem(APP_THEME_STORAGE_KEY, mode);
+ applyTheme();
+}
export function useTheme() {
- const isDark = ref(localStorage.getItem("dbx-theme") === "dark");
-
- function applyTheme() {
- document.documentElement.classList.toggle("dark", isDark.value);
- if (!isTauriRuntime()) return;
- import("@tauri-apps/api/window").then(({ getCurrentWindow }) => {
- getCurrentWindow()
- .setTheme(isDark.value ? ("dark" as Theme) : ("light" as Theme))
- .catch(() => {});
- });
- }
+ setupSystemThemeListener();
function toggleTheme() {
- isDark.value = !isDark.value;
- localStorage.setItem("dbx-theme", isDark.value ? "dark" : "light");
- applyTheme();
+ setThemeMode(isDark.value ? "light" : "dark");
}
- return { isDark, applyTheme, toggleTheme };
+ return { isDark, themeMode, applyTheme, setThemeMode, toggleTheme };
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index aa920663ac..3bd876efb8 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -34,6 +34,10 @@ export default {
formatSqlFailed: "Failed to format SQL",
saveSql: "Save to SQL Library",
openSql: "Open SQL file",
+ theme: "Theme",
+ themeLight: "Light",
+ themeDark: "Dark",
+ themeSystem: "Follow System",
sqlSaved: "SQL saved",
sqlOpenFailed: "Failed to open file: {message}",
sqlSaveFailed: "Failed to save file: {message}",
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 7f33acbc02..71a08ea668 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -34,6 +34,10 @@ export default {
formatSqlFailed: "Error al formatear el SQL",
saveSql: "Guardar en biblioteca SQL",
openSql: "Abrir archivo SQL",
+ theme: "Tema",
+ themeLight: "Claro",
+ themeDark: "Oscuro",
+ themeSystem: "Seguir sistema",
sqlSaved: "SQL guardado",
sqlOpenFailed: "Error al abrir el archivo: {message}",
sqlSaveFailed: "Error al guardar el archivo: {message}",
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
index e408dc6791..90fa92c0ba 100644
--- a/src/i18n/locales/zh-CN.ts
+++ b/src/i18n/locales/zh-CN.ts
@@ -34,6 +34,10 @@ export default {
formatSqlFailed: "SQL 格式化失败",
saveSql: "保存到 SQL 库",
openSql: "打开 SQL 文件",
+ theme: "主题",
+ themeLight: "亮色",
+ themeDark: "暗色",
+ themeSystem: "跟随系统",
sqlSaved: "SQL 已保存",
sqlOpenFailed: "打开文件失败:{message}",
sqlSaveFailed: "保存文件失败:{message}",
diff --git a/src/lib/appTheme.ts b/src/lib/appTheme.ts
new file mode 100644
index 0000000000..f25a624cce
--- /dev/null
+++ b/src/lib/appTheme.ts
@@ -0,0 +1,20 @@
+import type { Theme } from "@tauri-apps/api/window";
+
+export const APP_THEME_STORAGE_KEY = "dbx-theme";
+
+export type AppThemeMode = "light" | "dark" | "system";
+export type AppThemeAppearance = "light" | "dark";
+
+export function normalizeAppThemeMode(value: string | null): AppThemeMode {
+ if (value === "dark" || value === "light" || value === "system") return value;
+ return "light";
+}
+
+export function resolveAppThemeAppearance(mode: AppThemeMode, systemPrefersDark: boolean): AppThemeAppearance {
+ if (mode === "system") return systemPrefersDark ? "dark" : "light";
+ return mode;
+}
+
+export function getTauriThemeForMode(mode: AppThemeMode): Theme | null {
+ return mode === "system" ? null : mode;
+}
diff --git a/tests/appTheme.test.ts b/tests/appTheme.test.ts
new file mode 100644
index 0000000000..8195bca999
--- /dev/null
+++ b/tests/appTheme.test.ts
@@ -0,0 +1,28 @@
+import { strict as assert } from "node:assert";
+import test from "node:test";
+import {
+ getTauriThemeForMode,
+ normalizeAppThemeMode,
+ resolveAppThemeAppearance,
+} from "../src/lib/appTheme.ts";
+
+test("normalizes stored app theme modes", () => {
+ assert.equal(normalizeAppThemeMode("dark"), "dark");
+ assert.equal(normalizeAppThemeMode("light"), "light");
+ assert.equal(normalizeAppThemeMode("system"), "system");
+ assert.equal(normalizeAppThemeMode(null), "light");
+ assert.equal(normalizeAppThemeMode("unexpected"), "light");
+});
+
+test("resolves system app theme from current system preference", () => {
+ assert.equal(resolveAppThemeAppearance("light", true), "light");
+ assert.equal(resolveAppThemeAppearance("dark", false), "dark");
+ assert.equal(resolveAppThemeAppearance("system", true), "dark");
+ assert.equal(resolveAppThemeAppearance("system", false), "light");
+});
+
+test("maps system app theme mode to native automatic theme", () => {
+ assert.equal(getTauriThemeForMode("light"), "light");
+ assert.equal(getTauriThemeForMode("dark"), "dark");
+ assert.equal(getTauriThemeForMode("system"), null);
+});