diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 4218665e9b..66433dbe37 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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 }, ); diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 5ee1b8fc17..ba311be803 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -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()), ); diff --git a/apps/desktop/src/composables/__tests__/useTheme.spec.ts b/apps/desktop/src/composables/__tests__/useTheme.spec.ts index 99fef23bb7..79cfa7c1f0 100644 --- a/apps/desktop/src/composables/__tests__/useTheme.spec.ts +++ b/apps/desktop/src/composables/__tests__/useTheme.spec.ts @@ -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"); + }); }); diff --git a/apps/desktop/src/composables/useTheme.ts b/apps/desktop/src/composables/useTheme.ts index 3b0a6f5934..c5acba7f17 100644 --- a/apps/desktop/src/composables/useTheme.ts +++ b/apps/desktop/src/composables/useTheme.ts @@ -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 | 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, + }; } diff --git a/apps/desktop/src/lib/app/appFonts.ts b/apps/desktop/src/lib/app/appFonts.ts index a188256e83..54d5e8e44b 100644 --- a/apps/desktop/src/lib/app/appFonts.ts +++ b/apps/desktop/src/lib/app/appFonts.ts @@ -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. diff --git a/apps/desktop/src/lib/plugins/pluginAppearance.spec.ts b/apps/desktop/src/lib/plugins/pluginAppearance.spec.ts index d10cf05609..54bafbcaae 100644 --- a/apps/desktop/src/lib/plugins/pluginAppearance.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginAppearance.spec.ts @@ -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(); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginAppearance.ts b/apps/desktop/src/lib/plugins/pluginAppearance.ts index 848b7845d0..5e623ed16b 100644 --- a/apps/desktop/src/lib/plugins/pluginAppearance.ts +++ b/apps/desktop/src/lib/plugins/pluginAppearance.ts @@ -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 令牌名。 diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 334d77c973..ad33fbfb87 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -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 () => { diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 6672a0b63e..9303c818a8 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -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; + /** Editor appearance snapshot. Optional: older in-flight snapshots omit it. */ + editor?: PluginEditorAppearance; } export interface PluginWorkbenchContext { diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css index 0b75044ea5..c98f202198 100644 --- a/apps/desktop/src/styles/tokens.css +++ b/apps/desktop/src/styles/tokens.css @@ -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); diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 8f7951a90b..4498987696 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -316,6 +316,24 @@ button { 使用 `window.addEventListener("dbx-plugin-env", handler)` 响应语言或主题变化。先读取 `dbxPlugin.locale`/`theme` 初始化,再在事件中刷新自己的状态。DBX 提供轻量 `dbx-btn`、`dbx-input` 等样式,但开发宿主不模拟完整组件 kit,自定义 token 样式在两者中更一致。 +#### 主题负载携带什么 + +env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`;`tokens` 是按前缀扫取的根节点设计变量(`--color-*`、`--radius-*`、`--font-*`);`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵: + +| DBX 设置 | 投递形式 | +| --- | --- | +| 浅色/深色/跟随系统 | `theme.appearance` | +| 调色板 + 自定义配色 | `--color-*` tokens | +| 圆角风格 | `--radius-*` tokens | +| UI 字体 | `--font-sans` token | +| 编辑器(等宽)字体 | `--font-mono` token + `theme.editor.fontFamily` | +| 编辑器字号 | `theme.editor.fontSize` | +| SQL 编辑器语法主题 | `theme.editor.theme` | +| UI 缩放(宿主窗口级) | 不投递——插件自管缩放 | +| 数据网格字体/类型配色、编辑器背景图 | 不投递——DB 域(`--dbx-*` 不在扫取范围) | + +字体"字号"类关注点归插件所有:宿主不规定你的字号;如适合你的 UI,可把 `theme.editor.fontSize` 当默认值消费。所有负载变化都经 env 通道实时推送——字体族变更走主题修订号,字号/语法主题走显式监听——无需刷新。 + UI 必须构建为包内静态资源,不要把 Vite 开发服务器或 CDN 的脚本地址写入发行包。宿主把入口及资源转换成沙箱可加载内容;CSP 报错时检查构建产物是否存在、路径是否落在 `ui.root` 内,并使用匹配版本的 CLI 复现。用插件自己的对话框组件处理删除/重命名,不要依赖沙箱中的原生 `alert`/`confirm`/`prompt`。 ## 原生 Sidecar 和 RPC diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index e8e917b458..a84e50cf5c 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -316,6 +316,24 @@ button { Listen with `window.addEventListener("dbx-plugin-env", handler)` for locale/theme changes. Initialize from `dbxPlugin.locale`/`theme`, then refresh your state on events. DBX includes lightweight `dbx-btn` and `dbx-input` styles, but the development host does not emulate the complete component kit; custom token-based styles work more consistently in both. +#### What the theme payload carries + +The env message's `theme` is `{ appearance, tokens, editor? }`. `appearance` is `"light" | "dark"`; `tokens` are the resolved root design variables swept by prefix (`--color-*`, `--radius-*`, `--font-*`); `editor` is a structured snapshot of the SQL editor settings that have no CSS-token carrier. Delivered settings: + +| DBX setting | Delivered as | +| --- | --- | +| Light/dark/system mode | `theme.appearance` | +| Palette + custom color sets | `--color-*` tokens | +| Corner style | `--radius-*` tokens | +| UI font family | `--font-sans` token | +| Editor (mono) font family | `--font-mono` token + `theme.editor.fontFamily` | +| Editor font size | `theme.editor.fontSize` | +| SQL editor syntax theme | `theme.editor.theme` | +| UI scale (host window zoom) | not delivered — plugins own their own zoom | +| Data-grid font / type colors, editor background image | not delivered — DB-domain (`--dbx-*` tokens are excluded from the sweep) | + +Treat `terminal.fontSize`-style concerns as plugin-owned: the host never dictates your font size; consume `theme.editor.fontSize` as a default only if it suits your UI. All payload changes push live on the env channel — font-family edits via the theme revision, size/theme via the explicit editor watcher — so no refresh is needed. + Build the UI as packaged static assets, not references to a Vite server or CDN scripts. The host converts the entry and assets into sandbox-loadable content. For CSP errors, verify build outputs and confinement under `ui.root`, and reproduce with the matching CLI version. Use plugin-owned dialogs for delete/rename rather than relying on native `alert`/`confirm`/`prompt` inside the sandbox. ## Native Sidecars and RPC diff --git a/plugins/sdk/dev-host/runtime.mjs b/plugins/sdk/dev-host/runtime.mjs index 20929fbf9c..75a56436d5 100644 --- a/plugins/sdk/dev-host/runtime.mjs +++ b/plugins/sdk/dev-host/runtime.mjs @@ -1,11 +1,41 @@ import { spawn } from "node:child_process"; -import { mkdir, writeFile, chmod } from "node:fs/promises"; +import { mkdir, writeFile, chmod, readFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createMockHost } from "./server.mjs"; import { Diagnostics } from "./diagnostics.mjs"; import { stopProcessTree } from "./process-tree.mjs"; +// A go.work `go` directive must not be older than any workspace module's own +// directive, or go rejects the build outright ("requires go >= X, but go.work +// lists go Y"). Derive the directive from the listed modules' go.mod files and +// fall back to the historical base version when nothing is parseable. +const GO_WORK_BASE_VERSION = "1.22"; + +function compareGoVersions(left, right) { + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const delta = (a[i] || 0) - (b[i] || 0); + if (delta !== 0) return delta; + } + return 0; +} + +export async function resolveGoWorkVersion(directories) { + let version = GO_WORK_BASE_VERSION; + for (const directory of directories) { + try { + const goMod = await readFile(resolve(directory, "go.mod"), "utf8"); + const match = goMod.match(/^go\s+(\d+(?:\.\d+){1,2})/m); + if (match && compareGoVersions(match[1], version) > 0) version = match[1]; + } catch { + // No readable go.mod here; another workspace module decides the floor. + } + } + return version; +} + export async function startDevelopment(options) { const diagnostics = options.diagnostics || new Diagnostics(); if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("dbx-plugin dev requires Node.js 22+"); @@ -24,7 +54,8 @@ export async function startDevelopment(options) { const env = { ...process.env }; if (spec.goWorkspace) { const work = resolve(dataDir, "go.work"); - await writeFile(work, `go 1.22\n\nuse (\n${spec.goWorkspace.map((p) => "\t" + JSON.stringify(p)).join("\n")}\n)\n`, { mode: 0o600 }); + const goVersion = await resolveGoWorkVersion(spec.goWorkspace); + await writeFile(work, `go ${goVersion}\n\nuse (\n${spec.goWorkspace.map((p) => "\t" + JSON.stringify(p)).join("\n")}\n)\n`, { mode: 0o600 }); env.GOWORK = work; } let command = spec.command === "node" ? process.execPath : spec.command; diff --git a/plugins/sdk/dev-host/tests/runtime.test.mjs b/plugins/sdk/dev-host/tests/runtime.test.mjs index 4e2be60912..351bb0be70 100644 --- a/plugins/sdk/dev-host/tests/runtime.test.mjs +++ b/plugins/sdk/dev-host/tests/runtime.test.mjs @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { startDevelopment } from "../runtime.mjs"; +import { startDevelopment, resolveGoWorkVersion } from "../runtime.mjs"; import { Diagnostics } from "../diagnostics.mjs"; async function project(t) { @@ -27,6 +27,33 @@ async function project(t) { return { project: root, port: 0, shellHtml: join(root, "ui/index.html"), diagnostics: new Diagnostics(() => {}) }; } +test("go.work go directive tracks the newest module requirement", async (t) => { + const root = await mkdtemp(join(tmpdir(), "dbx-dev-gowork-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "newer"), { recursive: true }); + await mkdir(join(root, "older"), { recursive: true }); + await writeFile(join(root, "newer/go.mod"), "module example.com/newer\n\ngo 1.25.0\n"); + await writeFile(join(root, "older/go.mod"), "module example.com/older\n\ngo 1.21\n"); + assert.equal(await resolveGoWorkVersion([join(root, "newer"), join(root, "older")]), "1.25.0"); + // No readable go.mod: fall back to the historical base version. + assert.equal(await resolveGoWorkVersion([join(root, "missing")]), "1.22"); +}); + +test("backend build go.work is written with the module go directive", async (t) => { + const options = await project(t); + const moduleDirectory = join(options.project, "go-sdk"); + await mkdir(moduleDirectory, { recursive: true }); + await writeFile(join(moduleDirectory, "go.mod"), "module example.com/go-sdk\n\ngo 1.25.0\n"); + const host = await startDevelopment({ + ...options, + commands: { backend: { command: process.execPath, args: ["-e", "process.exit(0)"], goWorkspace: [moduleDirectory] } }, + }); + t.after(() => host.close()); + const work = await readFile(join(options.project, ".dbx-dev", "go.work"), "utf8"); + assert.match(work, /^go 1\.25\.0$/m); + assert.ok(work.includes(JSON.stringify(moduleDirectory)), "workspace lists the SDK module"); +}); + test("failed build does not start a host or retain signal listeners", async (t) => { const options = await project(t); const before = process.listenerCount("SIGTERM"); diff --git a/plugins/sdk/dev-host/ui/App.vue b/plugins/sdk/dev-host/ui/App.vue index 2395aca08a..629d819b0d 100644 --- a/plugins/sdk/dev-host/ui/App.vue +++ b/plugins/sdk/dev-host/ui/App.vue @@ -104,6 +104,8 @@ const themes = () => ({ "--color-primary-foreground": "#18181b", "--color-destructive": "#f3625f", "--color-destructive-foreground": "#18181b", + "--font-sans": '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + "--font-mono": "'Fira Code', 'Cascadia Code', 'Cascadia Mono', 'JetBrains Mono', monospace", "--radius-md": "6px", "--radius-lg": "8px", } @@ -121,6 +123,8 @@ const themes = () => ({ "--color-primary-foreground": "#ffffff", "--color-destructive": "#e7000b", "--color-destructive-foreground": "#ffffff", + "--font-sans": '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + "--font-mono": "'Fira Code', 'Cascadia Code', 'Cascadia Mono', 'JetBrains Mono', monospace", "--radius-md": "6px", "--radius-lg": "8px", },