From ba8df70fe60a104e0463ca7ebfcedbc45510c71c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=9F=A5=E9=A3=9F=E5=88=86=E5=AD=90?=
<54768887+wzlstudy@users.noreply.github.com>
Date: Thu, 27 Aug 2026 10:55:05 +0800
Subject: [PATCH] fix(desktop): add legacy webview fallbacks for snapshots and
dialogs
---
.../codeSnapshot/CodeSnapshotDialog.vue | 22 +++++-
.../components/grid/DataGridColumnHeader.vue | 12 ++-
.../src/components/layout/UpdateDialog.vue | 13 +++-
.../src/components/objects/DdlViewDialog.vue | 11 ++-
.../transfer/DataTransferDialog.vue | 8 +-
.../__tests__/codeSnapshot.spec.ts | 62 ++++++++++++++-
.../src/lib/codeSnapshot/codeSnapshot.ts | 78 ++++++++++++++++++-
.../__tests__/legacyWebviewFallback.spec.ts | 39 ++++++++++
8 files changed, 232 insertions(+), 13 deletions(-)
diff --git a/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue b/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue
index 508e063349..2cd9e56946 100644
--- a/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue
+++ b/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue
@@ -158,7 +158,7 @@ const previewVisible = computed(() => open.value && !!props.source);
+
+
diff --git a/apps/desktop/src/components/grid/DataGridColumnHeader.vue b/apps/desktop/src/components/grid/DataGridColumnHeader.vue
index 0001e419b3..30c3189eac 100644
--- a/apps/desktop/src/components/grid/DataGridColumnHeader.vue
+++ b/apps/desktop/src/components/grid/DataGridColumnHeader.vue
@@ -86,7 +86,7 @@ const emit = defineEmits<{
{{ columnNameLabel }}
{{ name }}
-
@@ -157,3 +157,13 @@ const emit = defineEmits<{
border-right: 2px solid rgb(100, 116, 139) !important;
}
+
+
diff --git a/apps/desktop/src/components/layout/UpdateDialog.vue b/apps/desktop/src/components/layout/UpdateDialog.vue
index 60276b42ba..90cd29993f 100644
--- a/apps/desktop/src/components/layout/UpdateDialog.vue
+++ b/apps/desktop/src/components/layout/UpdateDialog.vue
@@ -90,7 +90,7 @@ watch(
+
+
diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue
index 6ed4d5caf9..9338059853 100644
--- a/apps/desktop/src/components/objects/DdlViewDialog.vue
+++ b/apps/desktop/src/components/objects/DdlViewDialog.vue
@@ -224,7 +224,7 @@ function onClose() {
diff --git a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
index 79b2cd9e0c..0d0b965ac5 100644
--- a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
+++ b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
@@ -1,5 +1,12 @@
+// @vitest-environment happy-dom
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
+import { CODE_SNAPSHOT_CSS, materializeSnapshotCloneLineNumbers, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
+import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
+
+const codeSnapshotDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue"), "utf8");
const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => {
const highlightCode = vi.fn((content: string, _lang: string) => `${content}`);
@@ -78,6 +85,7 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain(".dbx-code-snapshot *");
expect(html).toContain("border: 0");
expect(html).toContain("outline: 0");
+ expect(html).toContain(".dbx-code-snapshot__line-number");
});
it("renders macOS traffic lights and an optional title by default", async () => {
@@ -124,8 +132,10 @@ describe("renderCodeSnapshotHtml", () => {
expect(dark).toContain("background:#0d1117");
expect(dark).toContain("--dbx-snapshot-line-number:#484f58");
+ expect(dark).toContain("color:#e1e4e8");
expect(light).toContain("background:#ffffff");
expect(light).toContain("--dbx-snapshot-line-number:#d0d7de");
+ expect(light).toContain("color:#24292f");
});
it("applies custom padding and font size", async () => {
@@ -135,12 +145,35 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain("padding:0 24px 24px");
});
+ it("keeps the snapshot dialog responsive layout classes in source", () => {
+ expect(codeSnapshotDialogSource).toContain("sm:max-w-[860px]");
+ expect(codeSnapshotDialogSource).toContain("md:flex-row");
+ expect(codeSnapshotDialogSource).toContain("md:w-52");
+ });
+
+ it("materializes real line-number nodes in the export clone", () => {
+ document.body.innerHTML = '
';
+ const root = document.body.firstElementChild as HTMLElement;
+
+ materializeSnapshotCloneLineNumbers(root);
+
+ const pre = root.querySelector(".dbx-code-snapshot__pre") as HTMLElement;
+ const lines = Array.from(root.querySelectorAll("code > .line"));
+ expect(pre.classList.contains("dbx-code-snapshot__pre--numbered")).toBe(false);
+ expect(lines).toHaveLength(2);
+ expect(lines[0]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("1");
+ expect(lines[1]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("2");
+ expect(lines[0]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("SELECT 1");
+ expect(lines[1]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("FROM dual");
+ });
+
it("exports at a capped device-pixel ratio for crisp high-DPI snapshots", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 3 });
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 640, scrollHeight: 240 } as HTMLElement);
+ const options = toPng.mock.calls.at(-1)?.[1];
expect(toPng).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
@@ -153,6 +186,8 @@ describe("renderCodeSnapshotHtml", () => {
},
}),
);
+ expect(options?.adjustPseudoElement).toBeUndefined();
+ expect(options?.onclone).toBeUndefined();
vi.stubGlobal("window", { devicePixelRatio: 0 });
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
@@ -162,6 +197,31 @@ describe("renderCodeSnapshotHtml", () => {
}
});
+ it("enables the legacy WebView export fallback only when the runtime is marked legacy", async () => {
+ toPng.mockResolvedValue("data:image/png;base64,test");
+ document.documentElement.classList.add(LEGACY_WEBVIEW_CLASS);
+ try {
+ await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
+
+ const options = toPng.mock.calls.at(-1)?.[1];
+ expect(typeof options?.adjustPseudoElement).toBe("function");
+ expect(typeof options?.onclone).toBe("function");
+ expect(options.adjustPseudoElement?.(document.createElement("span"), ":after", {} as CSSStyleDeclaration)).toBeUndefined();
+
+ const clone = document.createElement("div");
+ clone.innerHTML = '';
+ options.onclone?.(clone);
+
+ const line = clone.querySelector(".dbx-code-snapshot__line-number");
+ expect(line?.textContent).toBe("1");
+ expect(line?.getAttribute("style")).toContain("display: inline-block");
+ expect(line?.getAttribute("style")).toContain("margin-right: 1.4em");
+ expect(line?.getAttribute("style")).toContain("color: #d0d7de");
+ } finally {
+ document.documentElement.classList.remove(LEGACY_WEBVIEW_CLASS);
+ }
+ });
+
it("reduces the export scale when a wide snapshot reaches the canvas edge limit", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 2 });
diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
index 592d6efc08..6db78a5cd9 100644
--- a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
+++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
@@ -14,6 +14,7 @@
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { createAiShikiBlockCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
+import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
export interface CodeSnapshotSource {
code: string;
@@ -27,7 +28,7 @@ export interface CodeSnapshotStyleOptions {
appearance: AppThemeAppearance;
/** Render the macOS-style traffic light dots. Defaults to true. */
showTrafficLights?: boolean;
- /** Render line numbers via CSS counters. Defaults to true. */
+ /** Render line numbers. Defaults to true. */
showLineNumbers?: boolean;
/** Padding (px) around the code area. Defaults to 16. */
padding?: number;
@@ -55,6 +56,11 @@ const SNAPSHOT_LINE_NUMBER: Record = {
dark: "#484f58",
};
+const SNAPSHOT_CODE_TEXT: Record = {
+ light: "#24292f",
+ dark: "#e1e4e8",
+};
+
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
const MAX_EXPORT_PIXEL_RATIO = 2;
const MAX_EXPORT_CANVAS_EDGE = 16_384;
@@ -112,6 +118,16 @@ export const CODE_SNAPSHOT_CSS = `
display: block;
min-height: 1.6em;
}
+.dbx-code-snapshot__line-number {
+ display: inline-block;
+ min-width: 2.2em;
+ margin-right: 1.4em;
+ text-align: right;
+ color: var(--dbx-snapshot-line-number, #8b949e);
+ font-variant-numeric: tabular-nums;
+ user-select: none;
+ -webkit-user-select: none;
+}
.dbx-code-snapshot__pre--numbered {
counter-reset: dbx-snapshot-line;
}
@@ -160,6 +176,48 @@ function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppear
return `${dots}${titleHtml}
`;
}
+function isSnapshotNumberedLineNode(node: Node): node is Element {
+ return node instanceof Element && node.classList.contains("line") && node.closest(".dbx-code-snapshot__pre--numbered") !== null;
+}
+
+function hasMaterializedLineNumber(line: Element): boolean {
+ return Array.from(line.children).some((child) => child.classList.contains("dbx-code-snapshot__line-number"));
+}
+
+function legacyWebViewExportFallbackEnabled(): boolean {
+ return typeof document !== "undefined" && document.documentElement.classList.contains(LEGACY_WEBVIEW_CLASS);
+}
+
+/**
+ * Keep preview rendering on CSS counters, but rewrite the cloned export DOM to
+ * use real line-number nodes. Older WebKit foreignObject exports can flatten
+ * every counter() value to "1", while the live preview still looks correct.
+ */
+export function materializeSnapshotCloneLineNumbers(root: ParentNode): void {
+ for (const block of Array.from(root.querySelectorAll(".dbx-code-snapshot__pre--numbered"))) {
+ block.classList.remove("dbx-code-snapshot__pre--numbered");
+ const lineNumberColor = block.style.getPropertyValue("--dbx-snapshot-line-number").trim() || SNAPSHOT_LINE_NUMBER.dark;
+ const lines = Array.from(block.querySelectorAll("code > .line"));
+ for (const [index, line] of lines.entries()) {
+ if (hasMaterializedLineNumber(line)) continue;
+ const lineNumber = block.ownerDocument.createElement("span");
+ lineNumber.className = "dbx-code-snapshot__line-number";
+ lineNumber.setAttribute("aria-hidden", "true");
+ lineNumber.textContent = String(index + 1);
+ lineNumber.style.cssText = ["display:inline-block", "min-width:2.2em", "margin-right:1.4em", "text-align:right", `color:${lineNumberColor}`, "font-variant-numeric:tabular-nums", "user-select:none", "-webkit-user-select:none"].join(";");
+
+ const lineContent = block.ownerDocument.createElement("span");
+ lineContent.className = "dbx-code-snapshot__line-content";
+ lineContent.style.display = "inline";
+ while (line.firstChild) {
+ lineContent.append(line.firstChild);
+ }
+
+ line.append(lineNumber, lineContent);
+ }
+ }
+}
+
/**
* Render a self-contained snapshot DOM (as an HTML string) for the given code.
* The returned string includes its own `` +
`` +
bar +
- `
${body}
` +
+ `
${body}
` +
`
`
);
}
@@ -207,12 +265,28 @@ export async function snapshotElementToPng(element: HTMLElement): Promise {
+ if (pseudoElement === ":before" && isSnapshotNumberedLineNode(node)) {
+ return false;
+ }
+ return undefined;
+ }
+ : undefined,
+ onclone: useLegacyLineNumberFallback
+ ? (clone: Node) => {
+ if (typeof (clone as ParentNode).querySelectorAll === "function") {
+ materializeSnapshotCloneLineNumbers(clone as ParentNode);
+ }
+ }
+ : undefined,
style: {
width: `${width}px`,
height: `${height}px`,
diff --git a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
index 36ed198c4e..85fd4131e6 100644
--- a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
+++ b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
@@ -6,7 +6,11 @@ const globalsCss = readCascadeCss();
const dialogContentSource = readFileSync(new URL("../../components/ui/dialog/DialogContent.vue", import.meta.url), "utf8");
const dialogScrollContentSource = readFileSync(new URL("../../components/ui/dialog/DialogScrollContent.vue", import.meta.url), "utf8");
const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/DialogOverlay.vue", import.meta.url), "utf8");
+const codeSnapshotDialogSource = readFileSync(new URL("../../components/codeSnapshot/CodeSnapshotDialog.vue", import.meta.url), "utf8");
const dataTransferDialogSource = readFileSync(new URL("../../components/transfer/DataTransferDialog.vue", import.meta.url), "utf8");
+const updateDialogSource = readFileSync(new URL("../../components/layout/UpdateDialog.vue", import.meta.url), "utf8");
+const dataGridColumnHeaderSource = readFileSync(new URL("../../components/grid/DataGridColumnHeader.vue", import.meta.url), "utf8");
+const ddlViewDialogSource = readFileSync(new URL("../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const activeConnectionFilterSource = readFileSync(new URL("../../components/sidebar/ActiveConnectionFilterButton.vue", import.meta.url), "utf8");
@@ -86,10 +90,45 @@ describe("legacy WebView CSS fallbacks", () => {
expect(dataTransferDialogSource).toContain('width: "min(1120px, calc(100vw - 2rem))"');
expect(dataTransferDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-transfer-dialog[class~="max-w-sm"]');
expect(dataTransferDialogSource).toContain("max-width: calc(100vw - 2rem) !important;");
+ expect(dataTransferDialogSource).not.toContain("@media (min-width: 640px)");
expect(dataTransferDialogSource).not.toMatch(/^\s+width: calc\(100vw - 2rem\) !important;$/m);
expect(globalsCss).not.toContain(".dbx-transfer-dialog");
});
+ it("keeps the code snapshot dialog layout scoped to legacy WebViews without media queries", () => {
+ expect(codeSnapshotDialogSource).toContain('class="dbx-code-snapshot-dialog flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]"');
+ expect(codeSnapshotDialogSource).toContain('class="dbx-code-snapshot-dialog__footer"');
+ expect(codeSnapshotDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-code-snapshot-dialog');
+ expect(codeSnapshotDialogSource).toContain("width: calc(100vw - 2rem) !important;");
+ expect(codeSnapshotDialogSource).toContain("flex-direction: row !important;");
+ expect(codeSnapshotDialogSource).toContain("flex-wrap: nowrap !important;");
+ expect(codeSnapshotDialogSource).toContain("justify-content: flex-end !important;");
+ expect(codeSnapshotDialogSource).not.toContain("@media");
+ });
+
+ it("keeps the update dialog's wide layout fallback scoped to legacy WebViews without media queries", () => {
+ expect(updateDialogSource).toContain('class="dbx-update-dialog sm:max-w-[520px]"');
+ expect(updateDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog[class~="max-w-sm"]');
+ expect(updateDialogSource).toContain('dbx-update-dialog [data-slot="dialog-footer"]');
+ expect(updateDialogSource).not.toContain("@media");
+ });
+
+ it("keeps the DDL dialog's desktop layout scoped to legacy WebViews without media queries", () => {
+ expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog sm:max-w-190"');
+ expect(ddlViewDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog[class~="max-w-sm"]');
+ expect(ddlViewDialogSource).toContain("max-width: 47.5rem !important;");
+ expect(ddlViewDialogSource).toContain('dbx-ddl-view-dialog [data-slot="dialog-footer"]');
+ expect(ddlViewDialogSource).not.toContain("@media");
+ });
+
+ it("uses an explicit tooltip copy-button hover color in legacy WebViews", () => {
+ expect(dataGridColumnHeaderSource).toContain("data-column-header-copy-name");
+ expect(dataGridColumnHeaderSource).toContain("html.dbx-legacy-webview [data-column-header-copy-name]:hover");
+ expect(dataGridColumnHeaderSource).toContain("background-color: rgba(255, 255, 255, 0.1);");
+ expect(dataGridColumnHeaderSource).toContain("html.dbx-legacy-webview.dark [data-column-header-copy-name]:hover");
+ expect(dataGridColumnHeaderSource).toContain("background-color: rgba(0, 0, 0, 0.1);");
+ });
+
it("keeps primary alpha utilities readable in legacy WebViews", () => {
expect(globalsCss).toContain("--dbx-primary-rgb: 23, 23, 23;");
expect(globalsCss).toContain("--dbx-primary-rgb: 46, 95, 166;");