diff --git a/packages/op-vscode/package.json b/packages/op-vscode/package.json index 8beb332aa..1c30ed83c 100644 --- a/packages/op-vscode/package.json +++ b/packages/op-vscode/package.json @@ -27,6 +27,9 @@ "selector": [ { "filenamePattern": "*.op" + }, + { + "filenamePattern": "*.fig" } ], "priority": "default" diff --git a/packages/op-vscode/src/daemon/daemon-http.test.ts b/packages/op-vscode/src/daemon/daemon-http.test.ts index 905bd6bfe..16c4cc95c 100644 --- a/packages/op-vscode/src/daemon/daemon-http.test.ts +++ b/packages/op-vscode/src/daemon/daemon-http.test.ts @@ -132,3 +132,51 @@ test("rejects when the daemon never responds (injected short timeout)", async () }); await expect(new DaemonHttp(base, TOKEN, 200).version()).rejects.toThrow(); }); + +test("figmaConvert sends the token header and application/json content-type", async () => { + let seenToken: string | undefined; + let seenContentType: string | undefined; + let receivedBody = ""; + const base = await stub((req, res) => { + seenToken = req.headers["x-openpencil-token"] as string | undefined; + seenContentType = req.headers["content-type"] as string | undefined; + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", () => { + receivedBody = Buffer.concat(chunks).toString(); + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"ok":true,"doc":{"version":"1.0.0","children":[]}}'); + }); + }); + const json = await new DaemonHttp(base, TOKEN).figmaConvert("design.fig", "Zmln"); + expect(seenToken).toBe(TOKEN); + expect(seenContentType).toBe("application/json"); + expect(receivedBody).toBe('{"name":"design.fig","bytesB64":"Zmln"}'); + expect(json).toBe(JSON.stringify({ version: "1.0.0", children: [] })); +}); + +test("figmaConvert throws on {ok:false,error} (400)", async () => { + const base = await stub((_req, res) => { + res.writeHead(400, { "content-type": "application/json" }); + res.end('{"ok":false,"error":"malformed fig file"}'); + }); + await expect(new DaemonHttp(base, TOKEN).figmaConvert("design.fig", "Zmln")).rejects.toThrow( + "malformed fig file", + ); +}); + +test("figmaConvert throws on a non-200/400 status", async () => { + const base = await stub((_req, res) => { + res.writeHead(500, { "content-type": "text/plain" }); + res.end("internal error"); + }); + await expect(new DaemonHttp(base, TOKEN).figmaConvert("design.fig", "Zmln")).rejects.toThrow(); +}); + +test("figmaConvert throws when ok:true but doc is missing", async () => { + const base = await stub((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"ok":true}'); + }); + await expect(new DaemonHttp(base, TOKEN).figmaConvert("design.fig", "Zmln")).rejects.toThrow(); +}); diff --git a/packages/op-vscode/src/daemon/daemon-http.ts b/packages/op-vscode/src/daemon/daemon-http.ts index 1734f28a8..405c2f2b4 100644 --- a/packages/op-vscode/src/daemon/daemon-http.ts +++ b/packages/op-vscode/src/daemon/daemon-http.ts @@ -56,6 +56,38 @@ export class DaemonHttp { return await res.text(); } + /** POST /api/figma/convert: sends raw `.fig` bytes (base64) and returns the + * converted `.op` document, re-serialized as a JSON string (the caller uses + * it directly as bootJson). The daemon returns a JSON body on both 200 + * ({"ok":true,"doc":...}) and 400 ({"ok":false,"error":...}) — any other + * status is rejected on status alone, before attempting to parse a body + * that isn't guaranteed to be JSON (mirrors version()/getDocument()'s + * check-status-first style). Content-Type must be application/json — the + * daemon rejects sensitive POSTs without it. */ + async figmaConvert(name: string, bytesB64: string): Promise { + const res = await this.fetch("/api/figma/convert", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, bytesB64 }), + }); + if (res.status !== 200 && res.status !== 400) { + throw new Error(`figmaConvert: unexpected status ${res.status}`); + } + const body = (await res.json()) as unknown; + if (typeof body !== "object" || body === null) { + throw new Error(`figmaConvert: response is not an object (status ${res.status})`); + } + const rec = body as Record; + if (rec.ok !== true) { + const message = typeof rec.error === "string" ? rec.error : `unexpected status ${res.status}`; + throw new Error(`figmaConvert: ${message}`); + } + if (typeof rec.doc !== "object" || rec.doc === null) { + throw new Error(`figmaConvert: response missing "doc"`); + } + return JSON.stringify(rec.doc); + } + /** POST /mcp passthrough for the McpProxy: forwards a raw JSON-RPC body and * returns the status, headers, and body verbatim (plus the injected token). */ async mcpRaw( diff --git a/packages/op-vscode/src/extension.ts b/packages/op-vscode/src/extension.ts index 162617e0b..1d6f9aa58 100644 --- a/packages/op-vscode/src/extension.ts +++ b/packages/op-vscode/src/extension.ts @@ -11,6 +11,7 @@ import { DaemonPool } from "./daemon/daemon-pool"; import { McpProxy } from "./mcp/mcp-proxy"; import { SessionRegistry } from "./session/session-registry"; import { PenEditorProvider } from "./vscode/pen-editor-provider"; +import { isFigPath } from "./vscode/fig-source"; import { resolveDaemonBinary } from "./vscode/restart-source"; import { configureMcpCommand, removeMcpCommand } from "./vscode/configure-command"; import { installSkillCommand, removeSkillCommand } from "./vscode/skill-command"; @@ -157,7 +158,10 @@ function makeSpawn(logger: DaemonLogger) { if (!binary) throw new Error("op-host-web-server binary not found; set openpencil.dev.daemonPath"); return DaemonClient.spawn({ command: [binary], - filePath, + // The daemon's --file flag only understands .op JSON; a .fig source is + // converted after boot (see bootAndMount), so start it with the + // starter doc instead and deliver content via open-document. + filePath: isFigPath(filePath) ? undefined : filePath, allowOrigin, logger, expectedVersion: undefined, diff --git a/packages/op-vscode/src/vscode/fig-source.test.ts b/packages/op-vscode/src/vscode/fig-source.test.ts new file mode 100644 index 000000000..2daf36ee6 --- /dev/null +++ b/packages/op-vscode/src/vscode/fig-source.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { encodeFigBytes, figSaveTargetPath, isFigPath } from "./fig-source"; + +describe("fig source helpers", () => { + test("detects .fig case-insensitively", () => { + expect(isFigPath("/a/b/design.fig")).toBe(true); + expect(isFigPath("/a/b/design.FIG")).toBe(true); + expect(isFigPath("/a/b/design.op")).toBe(false); + expect(isFigPath("/a/b/fig")).toBe(false); + }); + test("save target is the sibling .op", () => { + expect(figSaveTargetPath("/a/b/design.fig")).toBe("/a/b/design.op"); + expect(figSaveTargetPath("/a/b/design.FIG")).toBe("/a/b/design.op"); + }); + test("encodes bytes as base64", () => { + expect(encodeFigBytes(new Uint8Array([102, 105, 103]))).toBe("Zmln"); + }); +}); diff --git a/packages/op-vscode/src/vscode/fig-source.ts b/packages/op-vscode/src/vscode/fig-source.ts new file mode 100644 index 000000000..43f22deaf --- /dev/null +++ b/packages/op-vscode/src/vscode/fig-source.ts @@ -0,0 +1,20 @@ +// Pure helpers for `.fig` sources: path detection, the sibling `.op` save +// target, and byte→base64 encoding for the daemon's `/api/figma/convert` +// endpoint. Kept vscode-free so these decisions are unit-testable. + +/** Case-insensitive `.fig` extension check. */ +export function isFigPath(fsPath: string): boolean { + return /\.fig$/i.test(fsPath); +} + +/** `.fig` files never save back to themselves — every save (implicit or + * backup-driven) lands on the sibling `.op`, e.g. `/a/b/design.fig` → + * `/a/b/design.op`. */ +export function figSaveTargetPath(figFsPath: string): string { + return figFsPath.replace(/\.fig$/i, ".op"); +} + +/** Base64-encode raw `.fig` bytes for the JSON body of the convert request. */ +export function encodeFigBytes(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} diff --git a/packages/op-vscode/src/vscode/pen-editor-provider.ts b/packages/op-vscode/src/vscode/pen-editor-provider.ts index 0121f11f7..8b7e2f777 100644 --- a/packages/op-vscode/src/vscode/pen-editor-provider.ts +++ b/packages/op-vscode/src/vscode/pen-editor-provider.ts @@ -16,6 +16,7 @@ import { encodeOutbound } from "../protocol/bridge"; import { buildBootHtml, buildWebviewHtml } from "./webview-shell"; import { pickRestartSource, resolveDaemonBinary, type LatestDurable } from "./restart-source"; import { isShellControl, parseShellReadyOrigin } from "./shell-messages"; +import { encodeFigBytes, figSaveTargetPath, isFigPath } from "./fig-source"; const SHELL_READY_TIMEOUT_MS = 5_000; const DAEMON_READY_TIMEOUT_MS = 10_000; @@ -29,6 +30,19 @@ export class PenDocument implements vscode.CustomDocument { readonly uri: vscode.Uri, readonly bootJson: string, initialSource: LatestDurable["source"], + /** True for every `.fig`-sourced doc — fresh from disk OR restored from a + * hot-exit backup. This (not `figBytesB64`) is THE fig-identity signal: + * every fig-aware branch (save target, watcher conversion, revert, + * restart) keys off `isFig`, because a backup-restored fig doc still + * must never write its converted JSON back onto the original `.fig`. */ + readonly isFig: boolean, + /** Base64 `.fig` bytes awaiting conversion. Set only for a `.fig` doc + * opened fresh from disk (not from a backup, which is already-converted + * JSON) — its presence alongside an empty `bootJson` is bootAndMount's + * signal to convert before mounting. A backup-restored fig doc has + * `isFig=true` but `figBytesB64=undefined`: it boots straight from the + * backup JSON, no conversion needed. */ + readonly figBytesB64?: string, ) { this.latestDurable = { source: initialSource, json: bootJson }; } @@ -73,11 +87,22 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { @@ -125,6 +150,16 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { @@ -246,7 +288,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider this.onDidChange.fire({ document }), writeFile: async (bytes) => { watcherState.suppressUntil = Date.now() + WATCHER_DEBOUNCE_MS; - await atomicWrite(document.uri, bytes); + await atomicWrite(this.saveTargetUri(document), bytes); document.latestDurable = { source: "save", json: decode(bytes) }; }, writeBackup: async (name, bytes) => { @@ -407,6 +465,17 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { + if (document.isFig) { + // Re-converting from disk here would need a DaemonHttp bound to the + // live mount's daemon, which this per-document callback doesn't have + // (only the watcher's onExternal closure does — see mountEditor). + // Revert to the last converted/saved state instead; a genuine edit to + // the .fig file on disk lands via that watcher path, not revert. + const json = document.latestDurable.json; + await this.requireSession(document).revert(json); + document.latestDurable = { source: "revert", json }; + return; + } const bytes = await vscode.workspace.fs.readFile(document.uri); const json = decode(bytes); await this.requireSession(document).revert(json); @@ -442,6 +511,13 @@ export class PenEditorProvider implements vscode.CustomEditorProvider