diff --git a/Cargo.lock b/Cargo.lock index da4c4b239f..5d26dae9f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8443,6 +8443,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http 1.5.0", + "image", "jni 0.21.1", "libc", "log", diff --git a/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue b/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue new file mode 100644 index 0000000000..0a389ea300 --- /dev/null +++ b/apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue @@ -0,0 +1,221 @@ + + + + + + + + + {{ t("codeSnapshot.title") }} + + + + + + + + + + + + + + + {{ t("codeSnapshot.theme") }} + + + + {{ t("codeSnapshot.themeLight") }} + + + + {{ t("codeSnapshot.themeDark") }} + + + + + + {{ t("codeSnapshot.windowControls") }} + + + + {{ t("codeSnapshot.lineNumbers") }} + + + + + {{ t("codeSnapshot.titleLabel") }} + + + + + + + {{ t("codeSnapshot.close") }} + + + + {{ copied ? t("codeSnapshot.copied") : t("codeSnapshot.copy") }} + + + + {{ t("codeSnapshot.save") }} + + + + + diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index c0e55aec09..2276d4bb4d 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -9,6 +9,7 @@ import { ArrowRightLeft, AlertTriangle, Bot, + Camera, Check, ChevronLeft, ChevronRight, @@ -44,6 +45,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useTheme } from "@/composables/useTheme"; +import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue"; +import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot"; import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore"; import AiProviderLogo from "@/components/icons/AiProviderLogo.vue"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -843,6 +846,14 @@ function sendProposalReply(positive: boolean) { const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`); const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light")); +const codeSnapshotOpen = ref(false); +const codeSnapshotSource = ref(null); + +function openCodeSnapshot(seg: { content: string; lang: string }) { + codeSnapshotSource.value = { code: seg.content, lang: seg.lang }; + codeSnapshotOpen.value = true; +} + const showActionButtons = computed(() => { if (!props.connection) return true; return !isVectorDbType(props.connection.db_type); @@ -2640,6 +2651,9 @@ async function openExternalUrl(url: string) { + + + @@ -3116,6 +3130,7 @@ async function openExternalUrl(url: string) { + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index d05d1a2655..b73f86c248 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1,6 +1,6 @@ ` }, { appearance: "dark" }); + + expect(html).toContain("<script>"); + expect(html).not.toContain(""); + }); + + it("applies the appearance-specific background and line-number color", async () => { + const dark = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark" }); + const light = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "light" }); + + expect(dark).toContain("background:#0d1117"); + expect(dark).toContain("--dbx-snapshot-line-number:#484f58"); + expect(light).toContain("background:#ffffff"); + expect(light).toContain("--dbx-snapshot-line-number:#d0d7de"); + }); + + it("applies custom padding and font size", async () => { + const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark", padding: 24, fontSize: 15 }); + + expect(html).toContain("font-size:15px"); + expect(html).toContain("padding:0 24px 24px"); + }); + + 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 } as HTMLElement); + + expect(toPng).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + width: 320, + height: 160, + pixelRatio: 2, + }), + ); + + vi.stubGlobal("window", { devicePixelRatio: 0 }); + await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160 } as HTMLElement); + expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ pixelRatio: 1 })); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("propagates a failed native save instead of reporting a browser download", async () => { + isTauriRuntime.mockReturnValue(true); + save.mockResolvedValue("C:\\Users\\example\\snapshot.png"); + writeFile.mockRejectedValue(new Error("disk full")); + + await expect(savePngDataUrlToFile("data:image/png;base64,AA==", "snapshot.png")).rejects.toThrow("disk full"); + expect(writeFile).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts new file mode 100644 index 0000000000..a5b87211e7 --- /dev/null +++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts @@ -0,0 +1,246 @@ +/** + * Code snapshot rendering & export utilities (CodeSnap-style). + * + * Flow: selected code text -> shiki re-render (reusing the AI highlighter + * singleton) -> CodeSnap-like shell DOM (traffic lights / title / line + * numbers / background) -> PNG export either to the system clipboard + * (Tauri `writeImage`) or to a file (dialog + fs, with a browser-download + * fallback for web mode). + * + * The rendered snapshot DOM is fully self-contained: it embeds its own + * `` + + `` + + bar + + `${body}` + + `` + ); +} + +/** Convert a snapshot DOM element into a PNG data URL. */ +export async function snapshotElementToPng(element: HTMLElement): Promise { + const domtoimage = (await import("dom-to-image-more")).default; + return domtoimage.toPng(element, { + quality: 1, + width: element.offsetWidth, + height: element.offsetHeight, + // Preserve text sharpness on high-DPI displays without letting a large + // selected block allocate an unbounded export canvas. + pixelRatio: Math.min(MAX_EXPORT_PIXEL_RATIO, Math.max(1, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1)), + }); +} + +async function dataUrlToBlob(dataUrl: string): Promise { + return await (await fetch(dataUrl)).blob(); +} + +/** + * Copy a PNG data URL to the system clipboard. Uses the Tauri + * clipboard-manager `writeImage` on desktop and falls back to the Web + * Clipboard API (`ClipboardItem`) for web mode. + */ +export async function copyPngDataUrlToClipboard(dataUrl: string): Promise { + const blob = await dataUrlToBlob(dataUrl); + if (isTauriRuntime()) { + const { writeImage } = await import("@tauri-apps/plugin-clipboard-manager"); + await writeImage(new Uint8Array(await blob.arrayBuffer())); + return; + } + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + return; + } + throw new Error("Clipboard image write is not supported in this environment"); +} + +/** + * Save a PNG data URL to a file. On desktop this opens the native save + * dialog; on web it downloads the image. Returns false when the user + * cancels the native save dialog. + */ +export async function savePngDataUrlToFile(dataUrl: string, defaultName: string): Promise { + const blob = await dataUrlToBlob(dataUrl); + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: defaultName, + filters: [{ name: "PNG", extensions: ["png"] }], + }); + if (!path) return false; + // Native write errors must reach the caller so the UI never claims the + // user-selected path was saved when the filesystem rejected it. + await writeFile(path, new Uint8Array(await blob.arrayBuffer())); + return true; + } + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = defaultName; + a.click(); + } finally { + URL.revokeObjectURL(url); + } + return true; +} diff --git a/flake.nix b/flake.nix index 9ffa866261..1b7dea2e2c 100644 --- a/flake.nix +++ b/flake.nix @@ -197,7 +197,7 @@ fetcherVersion = 4; # Update with the hash reported by a failed fixed-output build: # nix build .#dbx-pnpm-deps 2>&1 | grep 'got:' - hash = "sha256-wkjQz/nNx4D7p5B/5NcdXnMGvOljM+nGKyKDxvMRCgw="; + hash = "sha256-VYG+rEeTTcf3B4avT6UIAdwIymrrioAUtUP2MBYi6s0="; }; # ── Step 2: vendor Cargo dependencies ───────────────────────────── # diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15fee32408..4712fd719d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: version: 9.0.0 dom-to-image-more: specifier: ^3.7.2 - version: 3.7.2 + version: 3.10.2 echarts: specifier: ^6.1.0 version: 6.1.0 @@ -2223,8 +2223,8 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-to-image-more@3.7.2: - resolution: {integrity: sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==} + dom-to-image-more@3.10.2: + resolution: {integrity: sha512-+ZNbiRB7Fpsfzn8WSCmAIKUFSXp8uahlI+LsY8t8oWtAATqokeKS3eD3oQikheouap6aVMht8y0b4oAobWRr9w==} domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -5872,7 +5872,7 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 - dom-to-image-more@3.7.2: {} + dom-to-image-more@3.10.2: {} domelementtype@2.3.0: {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b54255427f..24b0f0b156 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" log = "0.4" -tauri = { version = "2.10.3", features = ["tray-icon"] } +tauri = { version = "2.10.3", features = ["tray-icon", "image-png"] } tauri-plugin-log = "2" tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 772922e238..8e7846975f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,7 @@ "core:webview:allow-set-webview-zoom", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", + "clipboard-manager:allow-write-image", "dialog:default", "dialog:allow-save", "dialog:allow-open",
${body}