feat(vscode): open .fig files via the daemon convert endpoint with sibling-.op saves
This commit is contained in:
parent
40da2ba9a6
commit
82611f0e9f
|
|
@ -27,6 +27,9 @@
|
|||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.op"
|
||||
},
|
||||
{
|
||||
"filenamePattern": "*.fig"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
packages/op-vscode/src/vscode/fig-source.test.ts
Normal file
18
packages/op-vscode/src/vscode/fig-source.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
20
packages/op-vscode/src/vscode/fig-source.ts
Normal file
20
packages/op-vscode/src/vscode/fig-source.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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 ----
|
||||
|
|
|
|||
Loading…
Reference in a new issue