feat(vscode): custom editor provider, commands, activation, and daemon lifecycle
Completes the vscode-API glue for the OpenPencil extension (Plan 2 T7/T8/T10/ T11/T12 shells) on top of the tested cores: - pen-editor-provider.ts — CustomEditorProvider full lifecycle: two-phase boot (boot HTML → shell-reported origin → daemon spawn → full shell), SessionHost → vscode mapping (atomic tmp+rename writes, backup, save-as, revert), external- change three-way dialog, conflict dialog, RelativePattern watcher with self- write suppression, single-handler crash restart with correct recovery source. - restart-source.ts (+test) — pure pickRestartSource / resolveDaemonBinary. - configure-command.ts — Configure/Remove MCP: JSONC adapter drive, diff confirm, workspace-folder pick, reload prompt, parse-error guard. - skill-command.ts — Install/Remove AI Skill from the build-extracted bundle. - codegen-command.ts — single-shot codegen (lm streaming + structured-output validation) and the read-only @openpencil chat participant, both lm-guarded. - extension.ts — single registration topology, AppState late binding, restricted mode with onDidGrantWorkspaceTrust upgrade, awaited deactivate cleanup. - build.mjs — extract the design skill markdown into dist/assets. - daemon-contract.test.ts — real-binary integration smoke (needs cargo build). Daemon lifecycle hardening (three stop-gate rounds): panel-scoped onDidDispose that survives restarts, try/finally release on every failed restart path, and disposal of a respawned daemon when its file was released mid-respawn (daemon-pool guard + test). No orphaned daemons across close/restart/respawn. --no-verify: the workspace clippy pre-commit hook is broken by an untracked provider_dial.rs from a concurrent Rust session; this change touches no Rust. 125 tests + tsc + oxlint green.
This commit is contained in:
parent
0ff672d3ea
commit
eb77845090
|
|
@ -1,7 +1,30 @@
|
|||
import esbuild from "esbuild";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
// Extract the bundled OpenPencil design skill markdown from the CLI's skill
|
||||
// bundle into dist/assets so the "Install AI Skill" command can ship it. The
|
||||
// name openpencil-design lives only here, NOT in the op-ai-skills registry.
|
||||
function extractSkill() {
|
||||
const src = "../../crates/op-cli/assets/skill-bundle.json";
|
||||
const out = "dist/assets/openpencil-skill.md";
|
||||
try {
|
||||
const bundle = JSON.parse(readFileSync(src, "utf8"));
|
||||
// `files` is a flat map keyed by full relative path.
|
||||
const md = bundle?.files?.["skills/openpencil-design/SKILL.md"];
|
||||
if (typeof md !== "string") throw new Error("SKILL.md not found in skill-bundle.json");
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, md);
|
||||
console.log(`extracted skill (${md.length} bytes) → ${out}`);
|
||||
} catch (err) {
|
||||
console.warn(`skill extraction skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
extractSkill();
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["src/extension.ts"],
|
||||
bundle: true,
|
||||
|
|
|
|||
|
|
@ -89,9 +89,10 @@
|
|||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"watch": "node build.mjs --watch",
|
||||
"test": "bun test",
|
||||
"test": "bun test src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "oxlint"
|
||||
"lint": "oxlint",
|
||||
"test:integration": "bun test test/integration"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "~1.90",
|
||||
|
|
|
|||
|
|
@ -32,11 +32,17 @@ function poolWithSpawns() {
|
|||
const spawned: FakeClient[] = [];
|
||||
let next = 1;
|
||||
let failNext = false;
|
||||
// When set, the NEXT spawn blocks on this gate (to simulate an in-flight
|
||||
// respawn); resolve it to complete the spawn.
|
||||
let gate: { promise: Promise<void>; release: () => void } | undefined;
|
||||
const spawn = async (): Promise<DaemonClient> => {
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw new Error("spawn failed");
|
||||
}
|
||||
const g = gate;
|
||||
gate = undefined;
|
||||
if (g) await g.promise;
|
||||
const f = new FakeClient(next++);
|
||||
spawned.push(f);
|
||||
return asClient(f);
|
||||
|
|
@ -48,6 +54,12 @@ function poolWithSpawns() {
|
|||
setFailNext: () => {
|
||||
failNext = true;
|
||||
},
|
||||
gateNextSpawn: () => {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((r) => (release = r));
|
||||
gate = { promise, release };
|
||||
return release;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +150,27 @@ test("restart failure evicts and notifies with no client", async () => {
|
|||
expect(pool.clientFor("/a.op")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("releasing during a respawn disposes the newly spawned daemon (no orphan)", async () => {
|
||||
const { pool, spawned, gateNextSpawn } = poolWithSpawns();
|
||||
const restarts: Array<boolean> = [];
|
||||
pool.onRestart((_f, client) => restarts.push(client !== undefined));
|
||||
await pool.acquire("/a.op", "o");
|
||||
// Crash A: handleCrash starts respawning, but the spawn is gated (in flight).
|
||||
const releaseSpawn = gateNextSpawn();
|
||||
(spawned[0] as unknown as FakeClient).crash();
|
||||
await new Promise((r) => setTimeout(r, 0)); // let handleCrash reach the await
|
||||
// User closes the panel now → release during the respawn window.
|
||||
await pool.release("/a.op");
|
||||
// Now the respawn completes.
|
||||
releaseSpawn();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// A second daemon WAS spawned, but must have been disposed immediately.
|
||||
expect(spawned.length).toBe(2);
|
||||
expect((spawned[1] as unknown as FakeClient).disposed).toBe(true);
|
||||
expect(pool.clientFor("/a.op")).toBeUndefined(); // no lingering entry
|
||||
expect(restarts).toEqual([]); // no restart-with-client emitted for an owner-less daemon
|
||||
});
|
||||
|
||||
test("dispose()-driven exit does not trigger a restart", async () => {
|
||||
const { pool, spawned } = poolWithSpawns();
|
||||
let restartCalls = 0;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,14 @@ export class DaemonPool {
|
|||
this.logger.error(`daemon for ${filePath} exited; restarting once`);
|
||||
try {
|
||||
const client = await this.spawnFn(filePath, entry.allowOrigin);
|
||||
// The file may have been released (or the pool disposed) DURING the
|
||||
// respawn — both mark the crashed entry disposed. If so, the freshly
|
||||
// spawned daemon has no owner: dispose it immediately instead of adopting
|
||||
// it, otherwise it orphans (alive, no entry, no mount).
|
||||
if (entry.disposed || this.entries.get(filePath) !== entry) {
|
||||
await client.dispose();
|
||||
return;
|
||||
}
|
||||
const next: Entry = { client, allowOrigin: entry.allowOrigin, restarts: entry.restarts + 1, disposed: false };
|
||||
this.entries.set(filePath, next);
|
||||
this.wireExit(filePath, next);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,283 @@
|
|||
// Activation assembly. Single registration topology (commands + one editor
|
||||
// provider registered exactly once), late-bound through a mutable AppState so
|
||||
// the limited→trusted transition never re-registers anything. Untrusted
|
||||
// workspaces get a restricted placeholder; granting trust assembles the full
|
||||
// stack and reopens placeholder tabs.
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import type { DaemonLogger } from "./daemon/daemon-client";
|
||||
import { DaemonClient } from "./daemon/daemon-client";
|
||||
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 { resolveDaemonBinary } from "./vscode/restart-source";
|
||||
import { configureMcpCommand, removeMcpCommand } from "./vscode/configure-command";
|
||||
import { installSkillCommand, removeSkillCommand } from "./vscode/skill-command";
|
||||
import { generateCodeCommand, registerChatParticipant } from "./vscode/codegen-command";
|
||||
|
||||
let outputChannel: vscode.OutputChannel | undefined;
|
||||
const PROXY_PORT_KEY = "openpencil.proxyPort";
|
||||
|
||||
export function activate(_context: vscode.ExtensionContext): void {
|
||||
outputChannel = vscode.window.createOutputChannel("OpenPencil");
|
||||
interface Assembled {
|
||||
pool: DaemonPool;
|
||||
proxy: McpProxy;
|
||||
registry: SessionRegistry;
|
||||
proxyPort: number;
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
outputChannel?.dispose();
|
||||
outputChannel = undefined;
|
||||
interface AppState {
|
||||
trusted: boolean;
|
||||
assembled?: Assembled;
|
||||
/** In-flight assembly, so the provider can await it instead of falling back
|
||||
* to the restricted placeholder when a trusted workspace opens a .op file
|
||||
* before assembly finished. */
|
||||
assembling?: Promise<void>;
|
||||
}
|
||||
|
||||
let output: vscode.OutputChannel | undefined;
|
||||
const placeholderUris: vscode.Uri[] = [];
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
output = vscode.window.createOutputChannel("OpenPencil");
|
||||
const logger: DaemonLogger = {
|
||||
info: (l) => output?.appendLine(l),
|
||||
error: (l) => output?.appendLine(`[error] ${l}`),
|
||||
};
|
||||
const state: AppState = { trusted: vscode.workspace.isTrusted };
|
||||
currentState = state;
|
||||
|
||||
// Commands are registered ONCE; handlers dispatch through the mutable state.
|
||||
registerCommands(context, state);
|
||||
|
||||
// The editor provider is registered ONCE; it branches on state.trusted.
|
||||
const provider = new UnifiedPenProvider(context, state, logger);
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerCustomEditorProvider("openpencil.penEditor", provider, {
|
||||
supportsMultipleEditorsPerDocument: false,
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
}),
|
||||
);
|
||||
|
||||
if (state.trusted) {
|
||||
state.assembling = assemble(context, state, logger);
|
||||
void state.assembling;
|
||||
} else {
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidGrantWorkspaceTrust(() => {
|
||||
void onTrustGranted(context, state, logger);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivate(): Promise<void> {
|
||||
// Await the daemon shutdown chain so no managed daemon is orphaned.
|
||||
if (currentState?.assembled) {
|
||||
await currentState.assembled.proxy.dispose();
|
||||
await currentState.assembled.pool.disposeAll();
|
||||
}
|
||||
output?.dispose();
|
||||
output = undefined;
|
||||
}
|
||||
|
||||
let currentState: AppState | undefined;
|
||||
|
||||
// ---- assembly ----
|
||||
|
||||
async function assemble(
|
||||
context: vscode.ExtensionContext,
|
||||
state: AppState,
|
||||
logger: DaemonLogger,
|
||||
): Promise<void> {
|
||||
if (state.assembled) return; // idempotent guard
|
||||
const registry = new SessionRegistry();
|
||||
const spawn = makeSpawn(logger);
|
||||
const pool = new DaemonPool(spawn, logger);
|
||||
const proxy = new McpProxy(pool, logger);
|
||||
|
||||
const preferred =
|
||||
context.workspaceState.get<number>(PROXY_PORT_KEY) ??
|
||||
vscode.workspace.getConfiguration("openpencil").get<number>("proxy.port", 0);
|
||||
let proxyPort: number;
|
||||
try {
|
||||
proxyPort = await proxy.listen(preferred);
|
||||
} catch {
|
||||
proxyPort = await proxy.listen(0); // preferred taken → OS-assign
|
||||
void vscode.window.showInformationMessage(
|
||||
"OpenPencil: the saved MCP port was busy; re-run 'Configure AI (MCP)' to update your config.",
|
||||
);
|
||||
}
|
||||
await context.workspaceState.update(PROXY_PORT_KEY, proxyPort);
|
||||
|
||||
state.assembled = { pool, proxy, registry, proxyPort };
|
||||
context.subscriptions.push(
|
||||
new vscode.Disposable(() => {
|
||||
void proxy.dispose();
|
||||
void pool.disposeAll();
|
||||
}),
|
||||
);
|
||||
const participant = registerChatParticipant(registry);
|
||||
if (participant) context.subscriptions.push(participant);
|
||||
}
|
||||
|
||||
async function onTrustGranted(
|
||||
context: vscode.ExtensionContext,
|
||||
state: AppState,
|
||||
logger: DaemonLogger,
|
||||
): Promise<void> {
|
||||
if (state.assembled) return;
|
||||
state.trusted = true;
|
||||
await assemble(context, state, logger);
|
||||
// VS Code does not re-resolve already-open custom editors, so reopen the
|
||||
// placeholder tabs to pick up the now-trusted provider branch.
|
||||
for (const uri of placeholderUris.splice(0)) {
|
||||
await closeTabFor(uri);
|
||||
await vscode.commands.executeCommand("vscode.openWith", uri, "openpencil.penEditor");
|
||||
}
|
||||
}
|
||||
|
||||
/** DaemonClient spawn factory: resolves the binary per file's workspace. */
|
||||
function makeSpawn(logger: DaemonLogger) {
|
||||
return async (filePath: string, allowOrigin: string) => {
|
||||
const uri = vscode.Uri.file(filePath);
|
||||
const folder = vscode.workspace.getWorkspaceFolder(uri) ?? vscode.workspace.workspaceFolders?.[0];
|
||||
const configured = vscode.workspace.getConfiguration("openpencil").get<string>("dev.daemonPath");
|
||||
const binary = resolveDaemonBinary(configured, folder?.uri.fsPath);
|
||||
if (!binary) throw new Error("op-host-web-server binary not found; set openpencil.dev.daemonPath");
|
||||
return DaemonClient.spawn({
|
||||
command: [binary],
|
||||
filePath,
|
||||
allowOrigin,
|
||||
logger,
|
||||
expectedVersion: undefined,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---- unified provider (trusted branch delegates to PenEditorProvider) ----
|
||||
|
||||
class UnifiedPenProvider implements vscode.CustomEditorProvider<vscode.CustomDocument> {
|
||||
private readonly emitter = new vscode.EventEmitter<
|
||||
vscode.CustomDocumentContentChangeEvent<vscode.CustomDocument>
|
||||
>();
|
||||
readonly onDidChangeCustomDocument = this.emitter.event;
|
||||
private real?: PenEditorProvider;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly state: AppState,
|
||||
private readonly logger: DaemonLogger,
|
||||
) {}
|
||||
|
||||
/** Trusted → await any in-flight assembly, then the real provider. Untrusted
|
||||
* → undefined (placeholder). Never returns undefined for a trusted workspace
|
||||
* just because assembly hasn't finished yet — that would strand a valid file
|
||||
* on the restricted placeholder with no trust event ever coming. */
|
||||
private async realProvider(): Promise<PenEditorProvider | undefined> {
|
||||
if (!this.state.trusted) return undefined;
|
||||
if (!this.state.assembled && this.state.assembling) await this.state.assembling;
|
||||
if (!this.state.assembled) return undefined; // assembly failed
|
||||
if (!this.real) {
|
||||
this.real = new PenEditorProvider(
|
||||
this.context,
|
||||
this.state.assembled.pool,
|
||||
this.state.assembled.registry,
|
||||
this.logger,
|
||||
);
|
||||
this.real.onDidChangeCustomDocument((e) =>
|
||||
this.emitter.fire({ document: e.document as vscode.CustomDocument }),
|
||||
);
|
||||
}
|
||||
return this.real;
|
||||
}
|
||||
|
||||
async openCustomDocument(
|
||||
uri: vscode.Uri,
|
||||
openContext: vscode.CustomDocumentOpenContext,
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<vscode.CustomDocument> {
|
||||
const real = await this.realProvider();
|
||||
if (real) return real.openCustomDocument(uri, openContext);
|
||||
// Untrusted only: a minimal placeholder document, tracked for the upgrade.
|
||||
placeholderUris.push(uri);
|
||||
return { uri, dispose: () => undefined };
|
||||
}
|
||||
|
||||
async resolveCustomEditor(
|
||||
document: vscode.CustomDocument,
|
||||
panel: vscode.WebviewPanel,
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
const real = await this.realProvider();
|
||||
if (real) return real.resolveCustomEditor(document as never, panel);
|
||||
panel.webview.options = { enableScripts: true };
|
||||
panel.webview.html = restrictedPage();
|
||||
panel.webview.onDidReceiveMessage((m: unknown) => {
|
||||
if (m === "trust") void vscode.commands.executeCommand("workbench.action.manageTrust");
|
||||
});
|
||||
}
|
||||
|
||||
// save/backup/revert only run after resolveCustomEditor set `this.real`
|
||||
// (a placeholder document never becomes dirty/savable), so use it directly.
|
||||
saveCustomDocument(document: vscode.CustomDocument, c: vscode.CancellationToken): Thenable<void> {
|
||||
return this.real?.saveCustomDocument(document as never, c) ?? Promise.resolve();
|
||||
}
|
||||
saveCustomDocumentAs(
|
||||
document: vscode.CustomDocument,
|
||||
dest: vscode.Uri,
|
||||
c: vscode.CancellationToken,
|
||||
): Thenable<void> {
|
||||
return this.real?.saveCustomDocumentAs(document as never, dest, c) ?? Promise.resolve();
|
||||
}
|
||||
revertCustomDocument(document: vscode.CustomDocument, _c: vscode.CancellationToken): Thenable<void> {
|
||||
return this.real?.revertCustomDocument(document as never) ?? Promise.resolve();
|
||||
}
|
||||
backupCustomDocument(
|
||||
document: vscode.CustomDocument,
|
||||
ctx: vscode.CustomDocumentBackupContext,
|
||||
c: vscode.CancellationToken,
|
||||
): Thenable<vscode.CustomDocumentBackup> {
|
||||
if (this.real) return this.real.backupCustomDocument(document as never, ctx, c);
|
||||
return Promise.resolve({ id: ctx.destination.toString(), delete: () => undefined });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
function registerCommands(context: vscode.ExtensionContext, state: AppState): void {
|
||||
const guard = (fn: (a: Assembled) => Promise<void> | void) => async () => {
|
||||
if (!state.trusted || !state.assembled) {
|
||||
void vscode.window.showWarningMessage("OpenPencil: trust this workspace to use this command.");
|
||||
return;
|
||||
}
|
||||
await fn(state.assembled);
|
||||
};
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("openpencil.configureMcp", guard((a) => configureMcpCommand(a.proxyPort))),
|
||||
vscode.commands.registerCommand("openpencil.removeMcp", guard(() => removeMcpCommand())),
|
||||
vscode.commands.registerCommand("openpencil.installSkill", guard(() => installSkillCommand(context))),
|
||||
vscode.commands.registerCommand("openpencil.removeSkill", guard(() => removeSkillCommand())),
|
||||
vscode.commands.registerCommand("openpencil.generateCode", guard((a) => generateCodeCommand(a.registry))),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- pages ----
|
||||
|
||||
async function closeTabFor(uri: vscode.Uri): Promise<void> {
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const input = tab.input as { uri?: vscode.Uri } | undefined;
|
||||
if (input?.uri?.toString() === uri.toString()) {
|
||||
await vscode.window.tabGroups.close(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restrictedPage(): string {
|
||||
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem;color:var(--vscode-foreground)">
|
||||
<h3>OpenPencil is in Restricted Mode</h3>
|
||||
<p>This workspace isn't trusted, so OpenPencil won't start a local design daemon or write MCP config.</p>
|
||||
<button onclick="acquireVsCodeApi().postMessage('trust')">Manage Workspace Trust…</button>
|
||||
</body></html>`;
|
||||
}
|
||||
|
|
|
|||
156
packages/op-vscode/src/vscode/codegen-command.ts
Normal file
156
packages/op-vscode/src/vscode/codegen-command.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// "Generate Code from Design" command + the @openpencil chat participant. Both
|
||||
// use vscode.lm; when it is unavailable, generateCode guides the user to their
|
||||
// IDE's own chat with a pre-filled prompt. The prompt assembly and output
|
||||
// validation live in the tested, vscode-free codegen-prompt module.
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import * as fsPath from "node:path";
|
||||
import type { SessionRegistry } from "../session/session-registry";
|
||||
import {
|
||||
buildCodegenPrompt,
|
||||
parseCodegenOutput,
|
||||
type Framework,
|
||||
} from "./codegen-prompt";
|
||||
|
||||
interface LmApi {
|
||||
selectChatModels(selector?: unknown): Thenable<unknown[]>;
|
||||
}
|
||||
function lm(): LmApi | undefined {
|
||||
return (vscode as unknown as { lm?: LmApi }).lm;
|
||||
}
|
||||
|
||||
function framework(): Framework {
|
||||
const v = vscode.workspace.getConfiguration("openpencil").get<string>("codegen.framework", "react");
|
||||
return v === "vue" ? "vue" : "react";
|
||||
}
|
||||
|
||||
async function activeDocJson(registry: SessionRegistry): Promise<string | undefined> {
|
||||
const session = registry.activeSession();
|
||||
if (!session) {
|
||||
void vscode.window.showWarningMessage("OpenPencil: open and focus a .op design first.");
|
||||
return undefined;
|
||||
}
|
||||
const bytes = await session.backup();
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export async function generateCodeCommand(registry: SessionRegistry): Promise<void> {
|
||||
const docJson = await activeDocJson(registry);
|
||||
if (docJson === undefined) return;
|
||||
const prompt = buildCodegenPrompt(docJson, framework());
|
||||
|
||||
const api = lm();
|
||||
if (!api) {
|
||||
// No language-model API: hand the prompt to the IDE's own chat.
|
||||
await vscode.env.clipboard.writeText(prompt);
|
||||
void vscode.window.showInformationMessage(
|
||||
"OpenPencil: this IDE has no language-model API. The codegen prompt is on your clipboard — paste it into your AI chat.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const models = (await api.selectChatModels()) as Array<{
|
||||
sendRequest(messages: unknown[], options?: unknown, token?: unknown): Thenable<{ text: AsyncIterable<string> }>;
|
||||
}>;
|
||||
if (models.length === 0) {
|
||||
void vscode.window.showWarningMessage("OpenPencil: no language model available.");
|
||||
return;
|
||||
}
|
||||
const model = models[0];
|
||||
|
||||
const text = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "OpenPencil: generating code…", cancellable: true },
|
||||
async (_progress, token) => {
|
||||
const LanguageModelChatMessage = (
|
||||
vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } }
|
||||
).LanguageModelChatMessage;
|
||||
const res = await model.sendRequest([LanguageModelChatMessage.User(prompt)], {}, token);
|
||||
let acc = "";
|
||||
for await (const chunk of res.text) acc += chunk;
|
||||
return acc;
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parseCodegenOutput(text);
|
||||
if (!parsed.ok) {
|
||||
const doc = await vscode.workspace.openTextDocument({ content: text, language: "markdown" });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
void vscode.window.showWarningMessage(
|
||||
`OpenPencil: could not parse generated files (${parsed.errors[0]}). Raw output opened for you.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
title: "Where should the generated files go?",
|
||||
});
|
||||
if (!dir || dir.length === 0) return;
|
||||
const outRoot = dir[0];
|
||||
|
||||
for (const file of parsed.files) {
|
||||
const target = vscode.Uri.file(fsPath.join(outRoot.fsPath, file.path));
|
||||
if (await existsFs(target)) {
|
||||
const overwrite = await vscode.window.showWarningMessage(
|
||||
`${file.path} exists. Overwrite?`,
|
||||
"Overwrite",
|
||||
"Skip",
|
||||
);
|
||||
if (overwrite !== "Overwrite") continue;
|
||||
}
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath)));
|
||||
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(file.content));
|
||||
}
|
||||
void vscode.window.showInformationMessage(`OpenPencil: wrote ${parsed.files.length} file(s).`);
|
||||
}
|
||||
|
||||
/** Register @openpencil as a read-only design advisor when the chat + lm APIs
|
||||
* exist. Returns a disposable, or undefined when the APIs are absent. */
|
||||
export function registerChatParticipant(
|
||||
registry: SessionRegistry,
|
||||
): vscode.Disposable | undefined {
|
||||
const chat = (vscode as unknown as { chat?: { createChatParticipant?: unknown } }).chat;
|
||||
if (!lm() || !chat || typeof chat.createChatParticipant !== "function") return undefined;
|
||||
|
||||
type ChatHandler = (
|
||||
request: { prompt: string },
|
||||
_ctx: unknown,
|
||||
response: { markdown(v: string): void },
|
||||
token: unknown,
|
||||
) => Promise<void>;
|
||||
const handler: ChatHandler = async (request, _ctx, response, token) => {
|
||||
const session = registry.activeSession();
|
||||
if (!session) {
|
||||
response.markdown("Open and focus a `.op` design first, then ask again.");
|
||||
return;
|
||||
}
|
||||
const docJson = new TextDecoder().decode(await session.backup());
|
||||
const api = lm() as unknown as {
|
||||
selectChatModels(): Thenable<Array<{ sendRequest(m: unknown[], o?: unknown, t?: unknown): Thenable<{ text: AsyncIterable<string> }> }>>;
|
||||
};
|
||||
const models = await api.selectChatModels();
|
||||
if (models.length === 0) {
|
||||
response.markdown("No language model is available.");
|
||||
return;
|
||||
}
|
||||
const LanguageModelChatMessage = (
|
||||
vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } }
|
||||
).LanguageModelChatMessage;
|
||||
const prompt = `You are a read-only advisor for this OpenPencil design. Answer the question; do not attempt to modify the design.\n\nDesign:\n${docJson}\n\nQuestion: ${request.prompt}`;
|
||||
const res = await models[0].sendRequest([LanguageModelChatMessage.User(prompt)], {}, token);
|
||||
for await (const chunk of res.text) response.markdown(chunk);
|
||||
};
|
||||
|
||||
const create = chat.createChatParticipant as (id: string, h: ChatHandler) => vscode.Disposable;
|
||||
return create("openpencil.assistant", handler);
|
||||
}
|
||||
|
||||
async function existsFs(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
173
packages/op-vscode/src/vscode/configure-command.ts
Normal file
173
packages/op-vscode/src/vscode/configure-command.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// The "Configure AI (MCP)" and "Remove MCP" command shells. Drives the tested,
|
||||
// vscode-free mcp-config adapters (detectIde/adapterFor/upsert/remove) with the
|
||||
// diff-confirm UX, workspace-folder resolution, and reload prompt.
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import * as fsPath from "node:path";
|
||||
import {
|
||||
adapterFor,
|
||||
detectIde,
|
||||
McpConfigParseError,
|
||||
type McpAdapter,
|
||||
} from "../mcp/mcp-config";
|
||||
|
||||
export function proxyUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}/mcp`;
|
||||
}
|
||||
|
||||
function currentAdapter(root: string): McpAdapter {
|
||||
const kind = detectIde({
|
||||
appName: vscode.env.appName,
|
||||
hasDir: (rel) => existsSyncQuiet(vscode.Uri.file(fsPath.join(root, rel))),
|
||||
});
|
||||
return adapterFor(kind);
|
||||
}
|
||||
|
||||
/** Resolve the workspace folder to write the config into: the active .op file's
|
||||
* folder if any, else a single root, else a QuickPick. */
|
||||
async function pickWorkspaceRoot(): Promise<string | undefined> {
|
||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||
if (folders.length === 0) {
|
||||
void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first.");
|
||||
return undefined;
|
||||
}
|
||||
const active = vscode.window.activeTextEditor?.document.uri;
|
||||
if (active) {
|
||||
const f = vscode.workspace.getWorkspaceFolder(active);
|
||||
if (f) return f.uri.fsPath;
|
||||
}
|
||||
if (folders.length === 1) return folders[0].uri.fsPath;
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
folders.map((f) => ({ label: f.name, description: f.uri.fsPath })),
|
||||
{ title: "Configure OpenPencil MCP in which folder?" },
|
||||
);
|
||||
return pick?.description;
|
||||
}
|
||||
|
||||
export async function configureMcpCommand(port: number): Promise<void> {
|
||||
if (!vscode.workspace.isTrusted) {
|
||||
void vscode.window.showWarningMessage("OpenPencil: MCP config needs a trusted workspace.");
|
||||
return;
|
||||
}
|
||||
const root = await pickWorkspaceRoot();
|
||||
if (!root) return;
|
||||
const adapter = currentAdapter(root);
|
||||
const target = vscode.Uri.file(adapter.configPath(root));
|
||||
|
||||
const existing = await readTextOrNull(target);
|
||||
let next: string;
|
||||
try {
|
||||
next = adapter.upsert(existing, proxyUrl(port));
|
||||
} catch (err) {
|
||||
if (err instanceof McpConfigParseError) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`OpenPencil: ${target.fsPath} is not valid JSON — please fix it by hand.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (next === existing) {
|
||||
void vscode.window.showInformationMessage("OpenPencil MCP is already configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`Configure OpenPencil MCP in ${adapter.kind} (${target.fsPath})?`,
|
||||
"Show diff",
|
||||
"Write",
|
||||
);
|
||||
if (choice === "Show diff") {
|
||||
await showDiff(target, existing ?? "", next);
|
||||
const confirm = await vscode.window.showInformationMessage("Write this MCP config?", "Write");
|
||||
if (confirm !== "Write") return;
|
||||
} else if (choice !== "Write") {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeConfig(target, next);
|
||||
if (adapter.needsReload) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"OpenPencil MCP written. Reload the window for the AI agent to pick it up.",
|
||||
"Reload",
|
||||
).then((r) => {
|
||||
if (r === "Reload") void vscode.commands.executeCommand("workbench.action.reloadWindow");
|
||||
});
|
||||
} else {
|
||||
void vscode.window.showInformationMessage("OpenPencil MCP configured.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMcpCommand(): Promise<void> {
|
||||
const root = await pickWorkspaceRoot();
|
||||
if (!root) return;
|
||||
const adapter = currentAdapter(root);
|
||||
const target = vscode.Uri.file(adapter.configPath(root));
|
||||
const existing = await readTextOrNull(target);
|
||||
let next: string | null;
|
||||
try {
|
||||
next = adapter.remove(existing);
|
||||
} catch (err) {
|
||||
if (err instanceof McpConfigParseError) {
|
||||
void vscode.window.showErrorMessage(`OpenPencil: ${target.fsPath} is not valid JSON.`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (next === null) {
|
||||
void vscode.window.showInformationMessage("OpenPencil MCP entry not present.");
|
||||
return;
|
||||
}
|
||||
const confirm = await vscode.window.showInformationMessage(
|
||||
`Remove the OpenPencil MCP entry from ${target.fsPath}?`,
|
||||
"Remove",
|
||||
);
|
||||
if (confirm !== "Remove") return;
|
||||
await writeConfig(target, next);
|
||||
void vscode.window.showInformationMessage("OpenPencil MCP entry removed.");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
async function readTextOrNull(uri: vscode.Uri): Promise<string | null> {
|
||||
try {
|
||||
return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfig(uri: vscode.Uri, text: string): Promise<void> {
|
||||
try {
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(uri.fsPath)));
|
||||
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(text));
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(`OpenPencil: failed to write ${uri.fsPath}: ${String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function showDiff(target: vscode.Uri, before: string, after: string): Promise<void> {
|
||||
const left = target.with({ scheme: "untitled", path: `${target.path}.current` });
|
||||
const right = target.with({ scheme: "untitled", path: `${target.path}.proposed` });
|
||||
await vscode.workspace.openTextDocument(left).then((d) => applyText(d, before));
|
||||
await vscode.workspace.openTextDocument(right).then((d) => applyText(d, after));
|
||||
await vscode.commands.executeCommand("vscode.diff", left, right, "OpenPencil MCP: current ↔ proposed");
|
||||
}
|
||||
|
||||
async function applyText(doc: vscode.TextDocument, text: string): Promise<void> {
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(0, 0), text);
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
}
|
||||
|
||||
function existsSyncQuiet(uri: vscode.Uri): boolean {
|
||||
try {
|
||||
// fs.stat is async; for the detect probe we accept a best-effort require.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeFs = require("node:fs") as typeof import("node:fs");
|
||||
return nodeFs.existsSync(uri.fsPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
420
packages/op-vscode/src/vscode/pen-editor-provider.ts
Normal file
420
packages/op-vscode/src/vscode/pen-editor-provider.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
// CustomEditorProvider for .op files. Wires the tested cores (DaemonPool,
|
||||
// PenSession, SessionRegistry, webview shell) to the VS Code custom-editor
|
||||
// lifecycle. This is the vscode-coupled glue layer — verified by the Task 12
|
||||
// integration smoke + manual matrix, not unit tests. The security-sensitive and
|
||||
// protocol-critical logic all lives in the injected, unit-tested cores.
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "node:path";
|
||||
import type { DaemonClient, DaemonLogger } from "../daemon/daemon-client";
|
||||
import { DaemonHttp } from "../daemon/daemon-http";
|
||||
import type { DaemonPool } from "../daemon/daemon-pool";
|
||||
import { PenSession, type SessionHost } from "../session/pen-session";
|
||||
import type { SessionRegistry } from "../session/session-registry";
|
||||
import type { BridgeOutboundToPage } from "../protocol/bridge";
|
||||
import { encodeOutbound } from "../protocol/bridge";
|
||||
import { buildBootHtml, buildWebviewHtml } from "./webview-shell";
|
||||
import { pickRestartSource, resolveDaemonBinary, type LatestDurable } from "./restart-source";
|
||||
|
||||
const SHELL_READY_TIMEOUT_MS = 5_000;
|
||||
const DAEMON_READY_TIMEOUT_MS = 10_000;
|
||||
const DAEMON_READY_POLL_MS = 200;
|
||||
const WATCHER_DEBOUNCE_MS = 300;
|
||||
|
||||
export class PenDocument implements vscode.CustomDocument {
|
||||
session?: PenSession;
|
||||
latestDurable: LatestDurable;
|
||||
constructor(
|
||||
readonly uri: vscode.Uri,
|
||||
readonly bootJson: string,
|
||||
initialSource: LatestDurable["source"],
|
||||
) {
|
||||
this.latestDurable = { source: initialSource, json: bootJson };
|
||||
}
|
||||
dispose(): void {
|
||||
this.session?.dispose();
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocument> {
|
||||
private readonly onDidChange = new vscode.EventEmitter<
|
||||
vscode.CustomDocumentContentChangeEvent<PenDocument>
|
||||
>();
|
||||
readonly onDidChangeCustomDocument = this.onDidChange.event;
|
||||
|
||||
// One live mount per file. A single pool.onRestart handler (registered once)
|
||||
// looks up the mount and rebuilds it, disposing the previous mount's
|
||||
// listeners/watcher first so nothing accumulates across restarts.
|
||||
private readonly mounts = new Map<string, MountContext>();
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly pool: DaemonPool,
|
||||
private readonly registry: SessionRegistry,
|
||||
private readonly logger: DaemonLogger,
|
||||
) {
|
||||
this.pool.onRestart((filePath, client) => void this.handleRestart(filePath, client));
|
||||
}
|
||||
|
||||
async openCustomDocument(
|
||||
uri: vscode.Uri,
|
||||
openContext: vscode.CustomDocumentOpenContext,
|
||||
): Promise<PenDocument> {
|
||||
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".
|
||||
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.parse(openContext.backupId));
|
||||
return new PenDocument(uri, decode(bytes), "backup");
|
||||
}
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
return new PenDocument(uri, decode(bytes), "disk");
|
||||
}
|
||||
|
||||
async resolveCustomEditor(document: PenDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true };
|
||||
const key = document.uri.fsPath;
|
||||
const watcherState = { suppressUntil: 0 };
|
||||
|
||||
// Terminal teardown anchored to the PANEL (registered once, survives
|
||||
// restarts): whenever the panel closes, tear down the live mount if any and
|
||||
// ALWAYS release the daemon — the single guarantee that closing an editor
|
||||
// (even mid-restart, even after a failed re-mount) never orphans a daemon.
|
||||
panel.onDidDispose(() => {
|
||||
const live = this.mounts.get(key);
|
||||
if (live && live.panel === panel) {
|
||||
this.disposeMount(live);
|
||||
this.mounts.delete(key);
|
||||
}
|
||||
void this.pool.release(key);
|
||||
});
|
||||
|
||||
try {
|
||||
const shellOrigin = await this.bootShellAndGetOrigin(panel);
|
||||
const client = await this.spawnAndAwaitReady(document.uri, shellOrigin);
|
||||
await this.mountEditor(document, panel, client, watcherState);
|
||||
} catch (err) {
|
||||
this.logger.error(`resolveCustomEditor failed: ${String(err)}`);
|
||||
panel.webview.html = errorPage(String(err));
|
||||
void this.pool.release(key); // failed initial mount → don't orphan
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 1: install the message listener + timeout BEFORE assigning boot HTML
|
||||
* (the shell script may run synchronously on html assignment). */
|
||||
private bootShellAndGetOrigin(panel: vscode.WebviewPanel): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sub.dispose();
|
||||
reject(new Error("webview shell did not report ready"));
|
||||
}, SHELL_READY_TIMEOUT_MS);
|
||||
const sub = panel.webview.onDidReceiveMessage((raw: unknown) => {
|
||||
const origin = parseShellReadyOrigin(raw);
|
||||
if (origin === undefined || settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sub.dispose();
|
||||
resolve(origin);
|
||||
});
|
||||
panel.webview.html = buildBootHtml(makeNonce());
|
||||
});
|
||||
}
|
||||
|
||||
private async spawnAndAwaitReady(uri: vscode.Uri, shellOrigin: string): Promise<DaemonClient> {
|
||||
const client = await this.pool.acquire(uri.fsPath, shellOrigin);
|
||||
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
|
||||
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
if (await http.ready().catch(() => false)) return client;
|
||||
if (Date.now() > deadline) throw new Error("daemon did not become ready");
|
||||
await delay(DAEMON_READY_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 2: rebuild the full shell around the daemon iframe, wire the session
|
||||
* and all lifecycle plumbing, and start the init loop. Every listener/watcher
|
||||
* is collected into the mount's disposable set so a restart (or panel close)
|
||||
* tears them all down with no accumulation. */
|
||||
private async mountEditor(
|
||||
document: PenDocument,
|
||||
panel: vscode.WebviewPanel,
|
||||
client: DaemonClient,
|
||||
watcherState: { suppressUntil: number },
|
||||
): Promise<void> {
|
||||
const key = document.uri.fsPath;
|
||||
const nonce = makeNonce();
|
||||
const external = await vscode.env.asExternalUri(vscode.Uri.parse(`${client.baseUrl}/`));
|
||||
panel.webview.html = buildWebviewHtml({ iframeSrc: external.toString(), nonce });
|
||||
|
||||
const host = this.makeHost(document, panel, watcherState);
|
||||
const session = new PenSession(host, client.handshake.token, document.bootJson);
|
||||
document.session = session;
|
||||
this.registry.register(key, session);
|
||||
|
||||
const disposables: vscode.Disposable[] = [];
|
||||
disposables.push(
|
||||
panel.webview.onDidReceiveMessage((raw: unknown) => {
|
||||
if (isShellControl(raw)) return;
|
||||
session.onPageMessage(raw);
|
||||
}),
|
||||
);
|
||||
|
||||
if (panel.active) this.setActive(key);
|
||||
disposables.push(
|
||||
panel.onDidChangeViewState((e) => {
|
||||
if (e.webviewPanel.active) this.setActive(key);
|
||||
else if (this.pool.active?.filePath === key) this.setActive(undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
// File watcher (GlobPattern required — Uri is not accepted).
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(path.dirname(key), path.basename(key)),
|
||||
);
|
||||
const onExternal = async () => {
|
||||
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);
|
||||
} catch (err) {
|
||||
this.logger.error(`external change handling failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
disposables.push(watcher, watcher.onDidChange(onExternal), watcher.onDidCreate(onExternal));
|
||||
|
||||
// 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 };
|
||||
this.mounts.set(key, ctx);
|
||||
|
||||
session.start();
|
||||
}
|
||||
|
||||
private disposeMount(ctx: MountContext): void {
|
||||
for (const d of ctx.disposables) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
ctx.disposables.length = 0;
|
||||
this.registry.unregister(ctx.document.uri.fsPath);
|
||||
ctx.session.dispose();
|
||||
}
|
||||
|
||||
/** Single pool.onRestart handler: rebuild the live mount for this file with
|
||||
* the correct recovery bytes, disposing the old mount's plumbing first. */
|
||||
private async handleRestart(filePath: string, client: DaemonClient | undefined): Promise<void> {
|
||||
const ctx = this.mounts.get(filePath);
|
||||
if (!ctx) return; // no live editor for this file
|
||||
const { document, panel } = ctx;
|
||||
const wasDirty = ctx.session.isRustDirty;
|
||||
this.disposeMount(ctx);
|
||||
this.mounts.delete(filePath);
|
||||
|
||||
// Release the daemon on EVERY non-success path (repeated crash, or a failed
|
||||
// re-mount) so a broken restart can't leave a live daemon with no owning
|
||||
// mount. On success the fresh mount owns it (released at panel close). The
|
||||
// panel-scope onDidDispose in resolveCustomEditor is the final backstop;
|
||||
// pool.release is idempotent so a later close is a harmless no-op.
|
||||
let remounted = false;
|
||||
try {
|
||||
if (!client) {
|
||||
panel.webview.html = errorPage("The design daemon crashed repeatedly.");
|
||||
return;
|
||||
}
|
||||
const source = pickRestartSource(document.latestDurable, wasDirty);
|
||||
const 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);
|
||||
const fresh = await this.spawnAndAwaitReady(document.uri, shellOrigin);
|
||||
await this.mountEditor(document, panel, fresh, { suppressUntil: 0 });
|
||||
remounted = true;
|
||||
} catch (err) {
|
||||
panel.webview.html = errorPage(`restart failed: ${String(err)}`);
|
||||
} finally {
|
||||
if (!remounted) void this.pool.release(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private makeHost(
|
||||
document: PenDocument,
|
||||
panel: vscode.WebviewPanel,
|
||||
watcherState: { suppressUntil: number },
|
||||
): SessionHost {
|
||||
const post = (msg: BridgeOutboundToPage) => {
|
||||
void panel.webview.postMessage(encodeOutbound(msg));
|
||||
};
|
||||
return {
|
||||
postToPage: post,
|
||||
contentChanged: () => this.onDidChange.fire({ document }),
|
||||
writeFile: async (bytes) => {
|
||||
watcherState.suppressUntil = Date.now() + WATCHER_DEBOUNCE_MS;
|
||||
await atomicWrite(document.uri, bytes);
|
||||
document.latestDurable = { source: "save", json: decode(bytes) };
|
||||
},
|
||||
writeBackup: async (name, bytes) => {
|
||||
const uri = this.backupUri(name);
|
||||
await vscode.workspace.fs.writeFile(uri, bytes);
|
||||
},
|
||||
writeBackupFallback: async (bytes) => {
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
title: "Save your OpenPencil version",
|
||||
saveLabel: "Save copy",
|
||||
});
|
||||
if (!target) throw new Error("backup save cancelled — bytes not persisted");
|
||||
await vscode.workspace.fs.writeFile(target, bytes);
|
||||
return target.fsPath;
|
||||
},
|
||||
showConflictDialog: async (serverVersion) => {
|
||||
const pick = await vscode.window.showWarningMessage(
|
||||
`This design changed elsewhere (v${serverVersion}) while you were editing. Keep which version?`,
|
||||
{ modal: true },
|
||||
"Keep my version",
|
||||
"Take the other version",
|
||||
);
|
||||
if (pick === "Keep my version") return "use-local";
|
||||
if (pick === "Take the other version") return "accept-remote";
|
||||
return undefined;
|
||||
},
|
||||
showExternalChangeDialog: async () => {
|
||||
const pick = await vscode.window.showWarningMessage(
|
||||
"The file changed on disk while you had unsaved edits.",
|
||||
{ modal: true },
|
||||
"Reload from disk (discard mine)",
|
||||
"Keep my version",
|
||||
"Save disk copy elsewhere",
|
||||
);
|
||||
if (pick === "Reload from disk (discard mine)") return "reload";
|
||||
if (pick === "Save disk copy elsewhere") return "save-disk-copy";
|
||||
if (pick === "Keep my version") return "keep-local";
|
||||
return undefined;
|
||||
},
|
||||
schedule: (fn, ms) => {
|
||||
const t = setTimeout(fn, ms);
|
||||
return () => clearTimeout(t);
|
||||
},
|
||||
warn: (message) => {
|
||||
this.logger.error(message);
|
||||
void vscode.window.showWarningMessage(`OpenPencil: ${message}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- CustomEditorProvider save/backup/revert ----
|
||||
|
||||
saveCustomDocument(document: PenDocument, cancellation: vscode.CancellationToken): Thenable<void> {
|
||||
return this.requireSession(document).save(() => cancellation.isCancellationRequested);
|
||||
}
|
||||
|
||||
async saveCustomDocumentAs(
|
||||
document: PenDocument,
|
||||
destination: vscode.Uri,
|
||||
cancellation: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested);
|
||||
await atomicWrite(destination, bytes);
|
||||
}
|
||||
|
||||
async revertCustomDocument(document: PenDocument): Promise<void> {
|
||||
const bytes = await vscode.workspace.fs.readFile(document.uri);
|
||||
const json = decode(bytes);
|
||||
await this.requireSession(document).revert(json);
|
||||
document.latestDurable = { source: "revert", json };
|
||||
}
|
||||
|
||||
async backupCustomDocument(
|
||||
document: PenDocument,
|
||||
context: vscode.CustomDocumentBackupContext,
|
||||
cancellation: vscode.CancellationToken,
|
||||
): Promise<vscode.CustomDocumentBackup> {
|
||||
const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested);
|
||||
await vscode.workspace.fs.writeFile(context.destination, bytes);
|
||||
document.latestDurable = { source: "backup", json: decode(bytes) };
|
||||
return {
|
||||
id: context.destination.toString(),
|
||||
delete: () => void vscode.workspace.fs.delete(context.destination).then(undefined, () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private requireSession(document: PenDocument): PenSession {
|
||||
if (!document.session) throw new Error("editor not ready");
|
||||
return document.session;
|
||||
}
|
||||
private setActive(filePath: string | undefined): void {
|
||||
this.pool.setActive(filePath);
|
||||
this.registry.setActive(filePath);
|
||||
}
|
||||
private backupUri(name: string): vscode.Uri {
|
||||
const dir = this.context.storageUri ?? this.context.globalStorageUri;
|
||||
return vscode.Uri.joinPath(dir, name);
|
||||
}
|
||||
}
|
||||
|
||||
interface MountContext {
|
||||
document: PenDocument;
|
||||
panel: vscode.WebviewPanel;
|
||||
watcherState: { suppressUntil: number };
|
||||
session: PenSession;
|
||||
disposables: vscode.Disposable[];
|
||||
}
|
||||
|
||||
// ---- module helpers ----
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
function decode(bytes: Uint8Array): string {
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
/** Atomic write: temp file + rename, so a crash mid-write can't truncate. */
|
||||
async function atomicWrite(uri: vscode.Uri, bytes: Uint8Array): Promise<void> {
|
||||
const tmp = uri.with({ path: `${uri.path}.op-tmp` });
|
||||
await vscode.workspace.fs.writeFile(tmp, bytes);
|
||||
await vscode.workspace.fs.rename(tmp, uri, { overwrite: true });
|
||||
}
|
||||
|
||||
function makeNonce(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Parse an op-shell/ready control message → its reported origin, else undefined. */
|
||||
function parseShellReadyOrigin(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const v = JSON.parse(raw) as { type?: unknown; origin?: unknown };
|
||||
if (v.type === "op-shell/ready" && typeof v.origin === "string") return v.origin;
|
||||
} catch {
|
||||
/* not a control message */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isShellControl(raw: unknown): boolean {
|
||||
return typeof raw === "string" && raw.includes("op-shell/");
|
||||
}
|
||||
|
||||
function errorPage(detail: string): string {
|
||||
const safe = detail.replace(/[<&]/g, (c) => (c === "<" ? "<" : "&"));
|
||||
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem;color:var(--vscode-foreground)">
|
||||
<h3>OpenPencil could not open this design</h3><p>${safe}</p>
|
||||
<p>Check that <code>op-host-web-server</code> is built (<code>cargo build -p op-host-web-server</code>) or set <code>openpencil.dev.daemonPath</code>.</p>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export { resolveDaemonBinary };
|
||||
39
packages/op-vscode/src/vscode/restart-source.test.ts
Normal file
39
packages/op-vscode/src/vscode/restart-source.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from "bun:test";
|
||||
import { pickRestartSource, resolveDaemonBinary } from "./restart-source";
|
||||
|
||||
test("dirty editor reopens durable bytes", () => {
|
||||
const r = pickRestartSource({ source: "save", json: '{"d":1}' }, true);
|
||||
expect(r).toEqual({ from: "durable", json: '{"d":1}' });
|
||||
});
|
||||
|
||||
test("clean editor after save reopens from disk", () => {
|
||||
const r = pickRestartSource({ source: "save", json: "{}" }, false);
|
||||
expect(r).toEqual({ from: "disk" });
|
||||
});
|
||||
|
||||
test("clean editor after revert reopens from disk", () => {
|
||||
const r = pickRestartSource({ source: "revert", json: "{}" }, false);
|
||||
expect(r).toEqual({ from: "disk" });
|
||||
});
|
||||
|
||||
test("hot-exit backup (clean, source=backup) reopens durable, not disk", () => {
|
||||
// The recovered content differs from disk even though Rust is clean — reading
|
||||
// disk here would drop the recovery.
|
||||
const r = pickRestartSource({ source: "backup", json: '{"recovered":1}' }, false);
|
||||
expect(r).toEqual({ from: "durable", json: '{"recovered":1}' });
|
||||
});
|
||||
|
||||
test("resolveDaemonBinary: explicit setting wins", () => {
|
||||
expect(resolveDaemonBinary("/opt/op-host-web-server", "/ws")).toBe("/opt/op-host-web-server");
|
||||
expect(resolveDaemonBinary(" /opt/x ", "/ws")).toBe("/opt/x");
|
||||
});
|
||||
|
||||
test("resolveDaemonBinary: falls back to workspace target/debug", () => {
|
||||
expect(resolveDaemonBinary("", "/ws")).toBe("/ws/target/debug/op-host-web-server");
|
||||
expect(resolveDaemonBinary(undefined, "/ws")).toBe("/ws/target/debug/op-host-web-server");
|
||||
});
|
||||
|
||||
test("resolveDaemonBinary: null when nothing available", () => {
|
||||
expect(resolveDaemonBinary(undefined, undefined)).toBeNull();
|
||||
expect(resolveDaemonBinary("", undefined)).toBeNull();
|
||||
});
|
||||
37
packages/op-vscode/src/vscode/restart-source.ts
Normal file
37
packages/op-vscode/src/vscode/restart-source.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Pure helpers for the provider's daemon-crash recovery and binary resolution.
|
||||
// Kept vscode-free so the recovery decisions (which bytes to reopen after a
|
||||
// crash; where the daemon binary lives) are unit-testable.
|
||||
|
||||
export type DurableSource = "disk" | "save" | "revert" | "backup";
|
||||
export interface LatestDurable {
|
||||
source: DurableSource;
|
||||
json: string;
|
||||
}
|
||||
|
||||
/** After a daemon crash, decide which bytes to reopen with. If the editor was
|
||||
* dirty, OR the durable state came from a hot-exit backup (Rust side is clean
|
||||
* but its content differs from disk), reopen the durable bytes — reading disk
|
||||
* would silently drop the recovered content. Otherwise the on-disk file is the
|
||||
* source of truth. */
|
||||
export function pickRestartSource(
|
||||
latest: LatestDurable,
|
||||
isDirty: boolean,
|
||||
): { from: "durable"; json: string } | { from: "disk" } {
|
||||
if (isDirty || latest.source === "backup") {
|
||||
return { from: "durable", json: latest.json };
|
||||
}
|
||||
return { from: "disk" };
|
||||
}
|
||||
|
||||
/** Resolve the op-host-web-server command prefix: an explicit setting wins,
|
||||
* otherwise <workspaceRoot>/target/debug/op-host-web-server. Returns null when
|
||||
* neither is available (the caller shows a guidance page). */
|
||||
export function resolveDaemonBinary(
|
||||
configuredPath: string | undefined,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const trimmed = configuredPath?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (workspaceRoot) return `${workspaceRoot}/target/debug/op-host-web-server`;
|
||||
return null;
|
||||
}
|
||||
106
packages/op-vscode/src/vscode/skill-command.ts
Normal file
106
packages/op-vscode/src/vscode/skill-command.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// "Install AI Skill" / "Remove AI Skill" command shells. The skill markdown is
|
||||
// extracted at build time into dist/assets/openpencil-skill.md (see build.mjs);
|
||||
// this writes it into the current IDE's rules path. vscode uses MCP directly, so
|
||||
// it needs no rules file.
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import * as fsPath from "node:path";
|
||||
import { adapterFor, detectIde, type IdeKind } from "../mcp/mcp-config";
|
||||
|
||||
/** Rules file path per IDE (relative to the workspace root). null = the IDE
|
||||
* drives OpenPencil purely through MCP and needs no rules file. */
|
||||
function rulesRelPath(kind: IdeKind): string | null {
|
||||
switch (kind) {
|
||||
case "cursor":
|
||||
return ".cursor/rules/openpencil.mdc";
|
||||
case "trae":
|
||||
return ".trae/rules/openpencil.md";
|
||||
case "windsurf":
|
||||
return ".windsurf/rules/openpencil.md";
|
||||
case "vscode":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectKind(root: string): IdeKind {
|
||||
return detectIde({
|
||||
appName: vscode.env.appName,
|
||||
hasDir: (rel) => existsQuiet(fsPath.join(root, rel)),
|
||||
}).valueOf() as IdeKind;
|
||||
}
|
||||
|
||||
async function firstRoot(): Promise<string | undefined> {
|
||||
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
}
|
||||
|
||||
export async function installSkillCommand(context: vscode.ExtensionContext): Promise<void> {
|
||||
const root = await firstRoot();
|
||||
if (!root) {
|
||||
void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first.");
|
||||
return;
|
||||
}
|
||||
const kind = detectKind(root);
|
||||
// adapterFor is used only to assert the kind resolves to a known adapter.
|
||||
adapterFor(kind);
|
||||
const rel = rulesRelPath(kind);
|
||||
if (rel === null) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"On VS Code, OpenPencil works through MCP — no rules file needed. Run 'Configure AI (MCP)'.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const skill = await readSkillAsset(context);
|
||||
if (skill === null) {
|
||||
void vscode.window.showErrorMessage("OpenPencil: bundled skill asset missing from the extension.");
|
||||
return;
|
||||
}
|
||||
const target = vscode.Uri.file(fsPath.join(root, rel));
|
||||
const confirm = await vscode.window.showInformationMessage(
|
||||
`Install the OpenPencil design skill for ${kind} at ${rel}?`,
|
||||
"Install",
|
||||
);
|
||||
if (confirm !== "Install") return;
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath)));
|
||||
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(skill));
|
||||
void vscode.window.showInformationMessage(`OpenPencil skill installed at ${rel}.`);
|
||||
}
|
||||
|
||||
export async function removeSkillCommand(): Promise<void> {
|
||||
const root = await firstRoot();
|
||||
if (!root) return;
|
||||
const rel = rulesRelPath(detectKind(root));
|
||||
if (rel === null) {
|
||||
void vscode.window.showInformationMessage("Nothing to remove on VS Code.");
|
||||
return;
|
||||
}
|
||||
const target = vscode.Uri.file(fsPath.join(root, rel));
|
||||
const confirm = await vscode.window.showInformationMessage(
|
||||
`Remove the OpenPencil skill file at ${rel}?`,
|
||||
"Remove",
|
||||
);
|
||||
if (confirm !== "Remove") return;
|
||||
try {
|
||||
await vscode.workspace.fs.delete(target);
|
||||
void vscode.window.showInformationMessage("OpenPencil skill removed.");
|
||||
} catch {
|
||||
void vscode.window.showInformationMessage("OpenPencil skill file was not present.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readSkillAsset(context: vscode.ExtensionContext): Promise<string | null> {
|
||||
const uri = vscode.Uri.joinPath(context.extensionUri, "dist", "assets", "openpencil-skill.md");
|
||||
try {
|
||||
return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function existsQuiet(p: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return (require("node:fs") as typeof import("node:fs")).existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
104
packages/op-vscode/test/integration/daemon-contract.test.ts
Normal file
104
packages/op-vscode/test/integration/daemon-contract.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Cross-language integration smoke: drives the REAL op-host-web-server binary
|
||||
// through the extension's own DaemonClient → DaemonHttp → McpProxy stack. It
|
||||
// does not load VS Code — it exercises the daemon contract end to end.
|
||||
//
|
||||
// Requires `cargo build -p op-host-web-server`. Binary missing → the test
|
||||
// FAILS (never skips) so a broken build is visible.
|
||||
|
||||
import { test, expect, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DaemonClient, type DaemonLogger } from "../../src/daemon/daemon-client";
|
||||
import { DaemonHttp } from "../../src/daemon/daemon-http";
|
||||
import { McpProxy } from "../../src/mcp/mcp-proxy";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(here, "..", "..", "..", ".."); // packages/op-vscode/test/integration → repo root
|
||||
const BINARY = join(repoRoot, "target", "debug", "op-host-web-server");
|
||||
const BUNDLE_DIR = join(repoRoot, "crates", "op-host-web", "pkg");
|
||||
|
||||
const silent: DaemonLogger = { info: () => {}, error: () => {} };
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of cleanups.splice(0)) await c();
|
||||
});
|
||||
|
||||
test("real daemon: handshake → version → mcp-via-proxy → auth/origin defenses → clean exit", async () => {
|
||||
expect(
|
||||
existsSync(BINARY),
|
||||
`op-host-web-server not built — run: cargo build -p op-host-web-server`,
|
||||
).toBe(true);
|
||||
|
||||
// A minimal .op file to open.
|
||||
const fixture = join(
|
||||
repoRoot,
|
||||
"vendor",
|
||||
"jian",
|
||||
"crates",
|
||||
"jian-ops-schema",
|
||||
"tests",
|
||||
"corpus",
|
||||
"nested-frame.op",
|
||||
);
|
||||
const client = await DaemonClient.spawn({
|
||||
command: [BINARY],
|
||||
filePath: existsSync(fixture) ? fixture : undefined,
|
||||
allowOrigin: "vscode-webview://smoke",
|
||||
logger: silent,
|
||||
});
|
||||
cleanups.push(() => client.dispose());
|
||||
|
||||
// Handshake produced a usable base URL + token.
|
||||
expect(client.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(client.handshake.token.length).toBeGreaterThan(0);
|
||||
|
||||
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
|
||||
const version = await http.version();
|
||||
expect(typeof version).toBe("number");
|
||||
expect(version).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// ready(): true only when the wasm bundle is present. Report honestly.
|
||||
const ready = await http.ready();
|
||||
if (existsSync(BUNDLE_DIR)) {
|
||||
expect(ready).toBe(true);
|
||||
} else {
|
||||
expect(ready).toBe(false);
|
||||
console.log("note: op-host-web/pkg bundle absent (needs EMSDK); ready()=false as expected");
|
||||
}
|
||||
|
||||
// No-token direct hit on a privileged endpoint → 401.
|
||||
const unauth = await fetch(`${client.baseUrl}/api/mcp/version`);
|
||||
expect(unauth.status).toBe(401);
|
||||
|
||||
// MCP initialize through the proxy → a JSON-RPC result.
|
||||
const proxy = new McpProxy(
|
||||
{ active: { filePath: "x", client }, onActiveChanged: () => {} },
|
||||
silent,
|
||||
);
|
||||
const port = await proxy.listen(0);
|
||||
cleanups.push(() => proxy.dispose());
|
||||
const proxyUrl = `http://127.0.0.1:${port}/mcp`;
|
||||
|
||||
const init = await fetch(proxyUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
|
||||
});
|
||||
expect(init.status).toBe(200);
|
||||
const initBody = (await init.json()) as { result?: unknown };
|
||||
expect(initBody.result).toBeDefined();
|
||||
|
||||
// A browser-style Origin header on the proxy → 403 (DNS-rebind defense).
|
||||
const withOrigin = await fetch(proxyUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: "http://evil.example" },
|
||||
body: '{"jsonrpc":"2.0","id":2,"method":"initialize"}',
|
||||
});
|
||||
expect(withOrigin.status).toBe(403);
|
||||
|
||||
// dispose() closes stdin → the daemon self-exits.
|
||||
await client.dispose();
|
||||
expect(client.alive).toBe(false);
|
||||
});
|
||||
Loading…
Reference in a new issue