feat(vscode): open .fig files via the daemon convert endpoint with sibling-.op saves

This commit is contained in:
Kayshen-X 2026-07-18 15:20:15 +08:00
parent 40da2ba9a6
commit 82611f0e9f
7 changed files with 215 additions and 11 deletions

View file

@ -27,6 +27,9 @@
"selector": [
{
"filenamePattern": "*.op"
},
{
"filenamePattern": "*.fig"
}
],
"priority": "default"

View file

@ -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();
});

View file

@ -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<string> {
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<string, unknown>;
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(

View file

@ -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,

View file

@ -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");
});
});

View file

@ -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");
}

View file

@ -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<PenDocumen
if (openContext.backupId) {
// Hot-exit recovery: the backup bytes are authoritative and must survive
// a subsequent daemon restart, so mark the durable source as "backup".
// A .fig doc's backup is already-converted JSON (PenSession backs up
// its live document state, not the raw .fig bytes), so it boots from
// the backup directly — no figBytesB64, no re-conversion — but isFig
// must still be set from the URI: this doc's uri is still the .fig
// file, and every fig-aware branch (save target, watcher, revert,
// restart) must keep treating it as one, or an implicit save would
// write converted JSON over the original .fig.
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.parse(openContext.backupId));
return new PenDocument(uri, decode(bytes), "backup");
return new PenDocument(uri, decode(bytes), "backup", isFigPath(uri.fsPath));
}
if (isFigPath(uri.fsPath)) {
const bytes = await vscode.workspace.fs.readFile(uri);
return new PenDocument(uri, "", "disk", true, encodeFigBytes(bytes));
}
const bytes = await vscode.workspace.fs.readFile(uri);
return new PenDocument(uri, decode(bytes), "disk");
return new PenDocument(uri, decode(bytes), "disk", false);
}
async resolveCustomEditor(document: PenDocument, panel: vscode.WebviewPanel): Promise<void> {
@ -125,6 +150,16 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
this.logger.info(`bootAndMount: shell origin=${shellOrigin} — spawning daemon`);
const client = await this.spawnAndAwaitReady(document.uri, shellOrigin);
if (this.staleCleanup(key, token)) return;
if (document.figBytesB64 !== undefined && document.bootJson === "") {
this.logger.info(`bootAndMount: converting .fig bytes for ${key}`);
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
const converted = await http.figmaConvert(path.basename(key), document.figBytesB64);
// Conversion can take seconds on large files; the panel may have
// closed meanwhile, so re-check staleness before touching document.
if (this.staleCleanup(key, token)) return;
(document as { bootJson: string }).bootJson = converted;
document.latestDurable = { source: "disk", json: converted };
}
this.logger.info(`bootAndMount: daemon ready at ${client.baseUrl} — mounting editor`);
await this.mountEditor(document, panel, client, watcherState);
if (this.staleAfterMount(key, token, panel)) return;
@ -222,6 +257,13 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
document.session = session;
this.registry.register(key, session);
// A fig doc's watcher re-converts on every external edit to the source
// .fig file, so it needs its own DaemonHttp bound to this mount's daemon
// — including a backup-restored fig doc (isFig=true, figBytesB64
// undefined): it booted without conversion, but a subsequent external
// .fig edit still needs one.
const daemonHttp = document.isFig ? new DaemonHttp(client.baseUrl, client.handshake.token) : undefined;
const disposables: vscode.Disposable[] = [];
disposables.push(
panel.webview.onDidReceiveMessage((raw: unknown) => {
@ -246,7 +288,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
if (Date.now() < watcherState.suppressUntil) return; // our own write
try {
const bytes = await vscode.workspace.fs.readFile(document.uri);
await session.externalFileChanged(decode(bytes), session.isRustDirty);
const json =
daemonHttp !== undefined
? await daemonHttp.figmaConvert(path.basename(key), encodeFigBytes(bytes))
: decode(bytes);
await session.externalFileChanged(json, session.isRustDirty);
} catch (err) {
this.logger.error(`external change handling failed: ${String(err)}`);
}
@ -256,7 +302,7 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
// Terminal teardown (daemon release + mount disposal on panel close) is
// anchored at the PANEL scope in resolveCustomEditor — registered once so it
// survives restarts. Here we only record the live mount.
const ctx: MountContext = { document, panel, watcherState, session, disposables };
const ctx: MountContext = { document, panel, watcherState, session, disposables, daemonHttp };
this.mounts.set(key, ctx);
session.start();
@ -303,11 +349,23 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
}
return;
}
const source = pickRestartSource(document.latestDurable, wasDirty);
const bootJson =
source.from === "durable"
? source.json
: decode(await vscode.workspace.fs.readFile(document.uri));
// A fig doc's on-disk bytes are raw fig-kiwi, never JSON — pickRestartSource's
// disk branch would decode them as text and hand the session garbage. Its
// latestDurable.json is always valid converted `.op` JSON (bootAndMount set
// it from the conversion; every save/backup/revert since keeps it current),
// so a fig restart always reopens from there, regardless of dirty state. Any
// .fig edit made on disk during the crash window is picked up separately by
// the watcher's onExternal reconvert on its next file-change event.
let bootJson: string;
if (document.isFig) {
bootJson = document.latestDurable.json;
} else {
const source = pickRestartSource(document.latestDurable, wasDirty);
bootJson =
source.from === "durable"
? source.json
: decode(await vscode.workspace.fs.readFile(document.uri));
}
(document as { bootJson: string }).bootJson = bootJson;
const shellOrigin = await this.bootShellAndGetOrigin(panel);
if (this.staleCleanup(filePath, token)) return;
@ -339,7 +397,7 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
contentChanged: () => 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<PenDocumen
}
async revertCustomDocument(document: PenDocument): Promise<void> {
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<PenDocumen
const dir = this.context.storageUri ?? this.context.globalStorageUri;
return vscode.Uri.joinPath(dir, name);
}
/** Where an implicit save (makeHost.writeFile) lands: a .fig doc redirects
* to its sibling .op (the .fig source is never overwritten); everything
* else saves back to its own uri. saveCustomDocumentAs is unaffected — a
* user-driven Save As keeps its explicit destination. */
private saveTargetUri(document: PenDocument): vscode.Uri {
return document.isFig ? vscode.Uri.file(figSaveTargetPath(document.uri.fsPath)) : document.uri;
}
}
interface MountContext {
@ -450,6 +526,9 @@ interface MountContext {
watcherState: { suppressUntil: number };
session: PenSession;
disposables: vscode.Disposable[];
/** Bound to this mount's daemon; set only when `document` is a `.fig` doc
* (used by the watcher's onExternal to re-convert on external edits). */
daemonHttp?: DaemonHttp;
}
// ---- module helpers ----