feat(desktop): deliver font settings to plugin sandboxes
This commit is contained in:
1 parent
ba6b74beea
commit
b1b99eccfb
15 files changed
+309
-25
No files matched your search
+23
-11
@@ -128,7 +128,7 @@ import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDri
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
|
||||
import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
|
||||
@@ -216,7 +216,7 @@ connectionStore.setBeforeConnectHandler(async (config) => {
|
||||
}
|
||||
});
|
||||
const { message: toastMessage, visible: toastVisible, action: toastAction, toast } = useToast();
|
||||
const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
|
||||
const { isDark, themeMode, applyTheme, setThemeMode, writeRootToken } = useTheme();
|
||||
const { activeCount: activeBackgroundTaskCount } = useExportTracker();
|
||||
const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs));
|
||||
const {
|
||||
@@ -1395,17 +1395,23 @@ function resetUiZoom() {
|
||||
showUiScaleToast();
|
||||
}
|
||||
|
||||
function applyUiFontFamily(fontFamily: string) {
|
||||
function applyUiFontFamily(fontFamily: string, options?: { debouncePluginBumpMs?: number }) {
|
||||
if (typeof document === "undefined") return;
|
||||
const next = fontFamily || DEFAULT_UI_FONT_FAMILY;
|
||||
// Override Tailwind's shared sans variable so app chrome and existing UI classes stay in sync.
|
||||
document.documentElement.style.setProperty(APP_FONT_SANS_CSS_VAR, next);
|
||||
// The plugin-facing bump is debounced while the preview slider/keystrokes fire.
|
||||
writeRootToken(APP_FONT_SANS_CSS_VAR, next, options?.debouncePluginBumpMs ? { debounceMs: options.debouncePluginBumpMs } : undefined);
|
||||
document.body.style.fontFamily = `var(${APP_FONT_SANS_CSS_VAR}, ${DEFAULT_UI_FONT_FAMILY})`;
|
||||
}
|
||||
|
||||
// Mirrors the editor font onto the global mono token so host mono surfaces and
|
||||
// plugin sandboxes (via the plugin bridge's token push) follow the same setting.
|
||||
function applyMonoFontFamily(fontFamily: string) {
|
||||
writeRootToken(FONT_MONO_CSS_VAR, fontFamily || DEFAULT_MONO_FONT_FAMILY);
|
||||
}
|
||||
|
||||
function applyDataGridFontFamily(fontFamily: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.style.setProperty(DATA_GRID_FONT_FAMILY_CSS_VAR, fontFamily || DEFAULT_DATA_GRID_FONT_FAMILY);
|
||||
writeRootToken(DATA_GRID_FONT_FAMILY_CSS_VAR, fontFamily || DEFAULT_DATA_GRID_FONT_FAMILY);
|
||||
}
|
||||
|
||||
// Both grid renderers read these variables, so overriding them here recolors the
|
||||
@@ -1490,11 +1496,17 @@ watch(
|
||||
watch(
|
||||
[() => settingsStore.editorSettings.uiFontFamily, uiFontFamilyPreview],
|
||||
([fontFamily, preview]) => {
|
||||
if (preview) {
|
||||
applyUiFontFamily(preview);
|
||||
return;
|
||||
}
|
||||
applyUiFontFamily(fontFamily);
|
||||
// Preview keystrokes write immediately (host feels instant) but coalesce
|
||||
// the plugin-bridge bump; committed changes bump right away.
|
||||
applyUiFontFamily(preview || fontFamily, preview ? { debouncePluginBumpMs: 150 } : undefined);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => settingsStore.editorSettings.fontFamily,
|
||||
(fontFamily) => {
|
||||
applyMonoFontFamily(fontFamily);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginSaveFileRequest, type PluginSaveFileResult, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
@@ -59,7 +60,11 @@ function currentBridgeTheme(): PluginBridgeTheme {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { appearance: isDark.value ? "dark" : "light", tokens };
|
||||
return {
|
||||
appearance: isDark.value ? "dark" : "light",
|
||||
tokens,
|
||||
editor: buildPluginEditorAppearance(settingsStore.editorSettings),
|
||||
};
|
||||
}
|
||||
|
||||
function createBridge() {
|
||||
@@ -249,11 +254,11 @@ watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
// path — dark/light, palette switch, custom colors — re-pushes the resolved
|
||||
// tokens; watching isDark/custom colors alone misses palette-only switches.
|
||||
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
|
||||
// Font settings are applied outside applyTheme() (see App.vue applyUiFontFamily)
|
||||
// and therefore never bump themeRevision; watch them explicitly so font token
|
||||
// changes reach live plugin bridges without waiting for the next theme switch.
|
||||
// Font families are mirrored onto root tokens by App.vue (writeRootToken) and
|
||||
// reach live bridges through the themeRevision bump above. fontSize and the
|
||||
// SQL editor syntax theme have no CSS-token carrier — watch them explicitly.
|
||||
watch(
|
||||
() => [settingsStore.editorSettings.uiFontFamily, settingsStore.editorSettings.fontFamily, settingsStore.editorSettings.fontSize],
|
||||
() => [settingsStore.editorSettings.fontSize, settingsStore.editorSettings.theme],
|
||||
() => bridge?.updateTheme(currentBridgeTheme()),
|
||||
);
|
||||
|
||||
|
||||
@@ -175,4 +175,55 @@ describe("useTheme on Linux", () => {
|
||||
expect(theme.customUiColors.value.background).toBe("#ffffff");
|
||||
expect(theme.customUiColorsDark.value.background).toBe("#1a1b1e");
|
||||
});
|
||||
|
||||
it("bumps the theme revision so out-of-band token writers (font settings) reach plugin bridges", async () => {
|
||||
const theme = await loadTheme("light");
|
||||
const before = theme.themeRevision.value;
|
||||
|
||||
theme.bumpThemeRevision();
|
||||
|
||||
expect(theme.themeRevision.value).toBe(before + 1);
|
||||
// No theme classes are touched: this is a pure revision bump.
|
||||
expect(document.documentElement.classList.contains("disable-transitions")).toBe(false);
|
||||
});
|
||||
|
||||
it("writeRootToken writes the inline override and bumps the revision", async () => {
|
||||
const theme = await loadTheme("light");
|
||||
const before = theme.themeRevision.value;
|
||||
|
||||
theme.writeRootToken("--dbx-test-token", "1px");
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--dbx-test-token")).toBe("1px");
|
||||
expect(theme.themeRevision.value).toBe(before + 1);
|
||||
document.documentElement.style.removeProperty("--dbx-test-token");
|
||||
});
|
||||
|
||||
it("writeRootToken coalesces rapid writes into one debounced bump", async () => {
|
||||
const theme = await loadTheme("light");
|
||||
const before = theme.themeRevision.value;
|
||||
|
||||
theme.writeRootToken("--dbx-test-token", "1px", { debounceMs: 30 });
|
||||
theme.writeRootToken("--dbx-test-token", "2px", { debounceMs: 30 });
|
||||
// Neither bump has fired yet, but the write itself is immediate.
|
||||
expect(theme.themeRevision.value).toBe(before);
|
||||
expect(document.documentElement.style.getPropertyValue("--dbx-test-token")).toBe("2px");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(theme.themeRevision.value).toBe(before + 1);
|
||||
document.documentElement.style.removeProperty("--dbx-test-token");
|
||||
});
|
||||
|
||||
it("writeRootToken with an immediate write supersedes a pending debounced bump", async () => {
|
||||
const theme = await loadTheme("light");
|
||||
const before = theme.themeRevision.value;
|
||||
|
||||
theme.writeRootToken("--dbx-test-token", "1px", { debounceMs: 30 });
|
||||
theme.writeRootToken("--dbx-test-token", "2px");
|
||||
expect(theme.themeRevision.value).toBe(before + 1);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
// The superseded timer must not fire a second bump.
|
||||
expect(theme.themeRevision.value).toBe(before + 1);
|
||||
document.documentElement.style.removeProperty("--dbx-test-token");
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,39 @@ const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemP
|
||||
// resolved token set (plugin iframe bridge) watch this revision instead.
|
||||
const themeRevision = ref(0);
|
||||
|
||||
// Token mutations on the root that happen outside applyTheme() — e.g. the UI
|
||||
// and editor font settings write --font-sans / --font-mono inline — bump this
|
||||
// so open plugin workbench bridges re-read and re-push the resolved tokens.
|
||||
function bumpThemeRevision() {
|
||||
themeRevision.value += 1;
|
||||
}
|
||||
|
||||
let pendingBumpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// The one sanctioned way to write a design token onto the root outside
|
||||
// applyTheme(): writes the inline override AND bumps the theme revision.
|
||||
// Writing root tokens directly (documentElement.style.setProperty) skips the
|
||||
// bump and open plugin bridges keep stale tokens — always go through here.
|
||||
// `debounceMs` coalesces rapid writes (font-family preview keystrokes) into
|
||||
// one bump; an immediate write afterwards supersedes the pending one.
|
||||
function writeRootToken(cssVar: string, value: string, options?: { debounceMs?: number }) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.style.setProperty(cssVar, value);
|
||||
if (options?.debounceMs) {
|
||||
if (pendingBumpTimer) clearTimeout(pendingBumpTimer);
|
||||
pendingBumpTimer = setTimeout(() => {
|
||||
pendingBumpTimer = undefined;
|
||||
bumpThemeRevision();
|
||||
}, options.debounceMs);
|
||||
return;
|
||||
}
|
||||
if (pendingBumpTimer) {
|
||||
clearTimeout(pendingBumpTimer);
|
||||
pendingBumpTimer = undefined;
|
||||
}
|
||||
bumpThemeRevision();
|
||||
}
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let isListeningForSystemTheme = false;
|
||||
let cachedTauriWindow: typeof import("@tauri-apps/api/window") | null = null;
|
||||
@@ -212,5 +245,25 @@ export function useTheme() {
|
||||
setThemeMode(isDark.value ? "light" : "dark");
|
||||
}
|
||||
|
||||
return { isDark, themeMode, themePalette, customUiColors, customUiColorsDark, activeCustomUiColors, themeRevision, cornerStyle, applyTheme, setThemeMode, setThemePalette, previewThemePalette, clearThemePalettePreview, setCustomUiColors, resetCustomUiColors, setCornerStyle, toggleTheme };
|
||||
return {
|
||||
isDark,
|
||||
themeMode,
|
||||
themePalette,
|
||||
customUiColors,
|
||||
customUiColorsDark,
|
||||
activeCustomUiColors,
|
||||
themeRevision,
|
||||
bumpThemeRevision,
|
||||
writeRootToken,
|
||||
cornerStyle,
|
||||
applyTheme,
|
||||
setThemeMode,
|
||||
setThemePalette,
|
||||
previewThemePalette,
|
||||
clearThemePalettePreview,
|
||||
setCustomUiColors,
|
||||
resetCustomUiColors,
|
||||
setCornerStyle,
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
export const APP_FONT_SANS_CSS_VAR = "--font-sans";
|
||||
export const FONT_MONO_CSS_VAR = "--font-mono";
|
||||
export const DATA_GRID_FONT_FAMILY_CSS_VAR = "--dbx-data-grid-font-family";
|
||||
|
||||
export const DEFAULT_UI_FONT_FAMILY = `"Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif`;
|
||||
// Keep in sync with the editor font default in settingsStore; also the baseline
|
||||
// `--font-mono` value in tokens.css, which plugin sandboxes consume via the bridge.
|
||||
export const DEFAULT_MONO_FONT_FAMILY = `'Fira Code', 'Cascadia Code', 'Cascadia Mono', 'JetBrains Mono', monospace`;
|
||||
export const DEFAULT_DATA_GRID_FONT_FAMILY = `"Geist Variable Tabular", "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif`;
|
||||
|
||||
// Native-feeling UI option without DBX's bundled/brand font at the front of the stack.
|
||||
|
||||
+20
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPluginAppearance, FALLBACK_APPEARANCE_COLORS, readAppearanceTokens } from "./pluginAppearance";
|
||||
import { buildPluginAppearance, buildPluginEditorAppearance, FALLBACK_APPEARANCE_COLORS, readAppearanceTokens } from "./pluginAppearance";
|
||||
|
||||
describe("buildPluginAppearance", () => {
|
||||
it("uses the pearl (white) fallback tokens for light and the .dark tokens for dark", () => {
|
||||
@@ -32,3 +32,22 @@ describe("buildPluginAppearance", () => {
|
||||
expect(() => readAppearanceTokens()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPluginEditorAppearance", () => {
|
||||
it("carries the editor font family, size and SQL syntax theme", () => {
|
||||
expect(buildPluginEditorAppearance({ fontFamily: "Fira Code", fontSize: 13, theme: "one-dark" })).toEqual({
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 13,
|
||||
theme: "one-dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined unless every field is usable (no partial snapshots)", () => {
|
||||
expect(buildPluginEditorAppearance({})).toBeUndefined();
|
||||
expect(buildPluginEditorAppearance({ fontFamily: "Fira Code" })).toBeUndefined();
|
||||
expect(buildPluginEditorAppearance({ fontFamily: "Fira Code", fontSize: 13 })).toBeUndefined();
|
||||
expect(buildPluginEditorAppearance({ fontFamily: "Fira Code", fontSize: 0, theme: "one-dark" })).toBeUndefined();
|
||||
expect(buildPluginEditorAppearance({ fontFamily: "Fira Code", fontSize: Number.NaN, theme: "one-dark" })).toBeUndefined();
|
||||
expect(buildPluginEditorAppearance({ fontFamily: " ", fontSize: 13, theme: "one-dark" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
// 插件沙箱外观快照:读取 DBX 根节点的设计令牌(随 .dark / 调色板类
|
||||
// 实时变化)加编辑器字体设置,组装成插件契约里的 appearance 对象,
|
||||
// 供 PluginHostBridge 在 init 下发、主题变化时实时推送。
|
||||
// 插件沙箱外观契约与快照工具:PluginAppearance 描述宿主下发的 appearance
|
||||
// 对象(1.0 部分下发、1.1 theme 通道只带颜色令牌);buildPluginAppearance
|
||||
// 组装快照,buildPluginEditorAppearance 产出结构化 editor 字段。实际推送
|
||||
// 链路在 PluginWorkbenchHost.currentBridgeTheme → PluginHostBridge.updateTheme。
|
||||
|
||||
import type { PluginEditorAppearance } from "./pluginHostBridge";
|
||||
|
||||
export type { PluginEditorAppearance } from "./pluginHostBridge";
|
||||
|
||||
export interface PluginAppearanceColors {
|
||||
background: string;
|
||||
@@ -16,8 +21,26 @@ export interface PluginAppearanceColors {
|
||||
export interface PluginAppearance {
|
||||
colorScheme: "light" | "dark";
|
||||
colors: PluginAppearanceColors;
|
||||
// terminal.fontFamily comes from the host's --font-mono token. It is a
|
||||
// mirror of the editor font for read-only following; terminal.fontSize is
|
||||
// NOT delivered and stays plugin-owned (the SSH terminal, for one, has its
|
||||
// own zoom that persists per workbench). The full editor settings — family,
|
||||
// size and SQL syntax theme — are delivered structured as `editor`.
|
||||
terminal: { fontFamily: string; fontSize: number };
|
||||
ui?: { fontFamily: string };
|
||||
editor?: PluginEditorAppearance;
|
||||
}
|
||||
|
||||
/** Structured editor appearance for the bridge theme: SQL editor settings that
|
||||
* have no CSS-token carrier (the size is a number, the syntax theme an id).
|
||||
* Returns undefined unless every field is usable, so snapshots for hosts with
|
||||
* incomplete settings stay unchanged instead of carrying partial data. */
|
||||
export function buildPluginEditorAppearance(editor: { fontFamily?: string; fontSize?: number; theme?: string }): PluginEditorAppearance | undefined {
|
||||
const fontFamily = typeof editor.fontFamily === "string" && editor.fontFamily.trim() ? editor.fontFamily : undefined;
|
||||
const fontSize = typeof editor.fontSize === "number" && Number.isFinite(editor.fontSize) && editor.fontSize > 0 ? editor.fontSize : undefined;
|
||||
const theme = typeof editor.theme === "string" && editor.theme.trim() ? editor.theme : undefined;
|
||||
if (!fontFamily || fontSize === undefined || !theme) return undefined;
|
||||
return { fontFamily, fontSize, theme };
|
||||
}
|
||||
|
||||
// camelCase 字段 → DBX globals.css 的 CSS 令牌名。
|
||||
|
||||
@@ -490,6 +490,11 @@ describe("PluginHostBridge", () => {
|
||||
|
||||
bridge.updateTheme({ appearance: "light", tokens: {} });
|
||||
expect(messages[1]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light", tokens: {} } });
|
||||
|
||||
// The structured editor snapshot rides along on every theme push.
|
||||
const editor = { fontFamily: "Fira Code", fontSize: 13, theme: "one-dark" };
|
||||
bridge.updateTheme({ appearance: "light", tokens: {}, editor });
|
||||
expect(messages[2]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light", tokens: {}, editor } });
|
||||
});
|
||||
|
||||
it("routes host.saveFile transfers through the host save dialog and reports cancellation", async () => {
|
||||
|
||||
@@ -10,10 +10,21 @@ const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024;
|
||||
// plugin iframe to disk and never traverse plugin frames.
|
||||
const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
/** Structured editor appearance: SQL editor settings that have no CSS-token
|
||||
* carrier (font size is a number, the syntax theme an id). Font families are
|
||||
* additionally mirrored as `--font-sans` / `--font-mono` root tokens. */
|
||||
export interface PluginEditorAppearance {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface PluginBridgeTheme {
|
||||
appearance: "light" | "dark";
|
||||
/** Resolved DBX design tokens (`--color-*`, `--radius-*`, ...) for the current theme. */
|
||||
/** Resolved DBX design tokens (`--color-*`, `--radius-*`, `--font-*`, ...) for the current theme. */
|
||||
tokens: Record<string, string>;
|
||||
/** Editor appearance snapshot. Optional: older in-flight snapshots omit it. */
|
||||
editor?: PluginEditorAppearance;
|
||||
}
|
||||
|
||||
export interface PluginWorkbenchContext {
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
@theme inline {
|
||||
--font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
/* Baseline mono token; App.vue overrides it inline with the editor font
|
||||
setting, and plugin sandboxes receive the resolved value via the bridge. */
|
||||
--font-mono: 'Fira Code', 'Cascadia Code', 'Cascadia Mono', 'JetBrains Mono', monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
Reference in new issue
Block a user