Files
awesome-copilot/extensions/draw/extension.mjs
T

104 lines
3.9 KiB
JavaScript

// Draw: a canvas for quick diagrams. Shapes, arrows that stay attached, text and pen.
// Drawings are JSON files in this session's workspace (files/drawings). One local
// HTTP server serves every open Draw panel in the session.
//
// Note: stdout is the JSON-RPC channel to the host, so never use console.log here.
import os from "node:os";
import path from "node:path";
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
import { DrawingStore, StoreError } from "./store.mjs";
import { Settings, settingsFile } from "./settings.mjs";
import { createDrawServer } from "./server.mjs";
import { makeActions } from "./actions.mjs";
let session = null;
let runtimePromise = null;
const log = (msg) => process.stderr.write(`[draw] ${msg}\n`);
function workspaceDir(ctx) {
if (session && session.workspacePath) return session.workspacePath;
const home = process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
return path.join(home, "session-state", ctx.sessionId);
}
// The store and server start on first use, so an unused canvas costs nothing.
function runtime(ctx) {
if (!runtimePromise) {
runtimePromise = (async () => {
const store = new DrawingStore(path.join(workspaceDir(ctx), "files", "drawings"), { log });
await store.ready;
const settings = new Settings(settingsFile(), { log });
const server = createDrawServer({ store, settings, getSession: () => session, log });
await server.start();
return { store, server };
})();
runtimePromise.catch((err) => {
log(`failed to start: ${err.stack || err.message}`);
runtimePromise = null;
});
}
return runtimePromise;
}
const canvas = createCanvas({
id: "draw",
displayName: "Draw",
description:
"Draw simple diagrams: boxes, ellipses, diamonds and databases joined by arrows that stay attached, plus text and a freehand pen. The user draws by hand and you can read and change the same drawing with actions. Drawings are saved in this session.",
inputSchema: {
type: "object",
properties: {
drawing: {
type: "string",
description:
"Name or id of the drawing to show. It is created if nothing matches. If a name could mean more than one drawing, nothing opens and the error lists their ids; an id always picks one.",
},
},
additionalProperties: false,
},
actions: makeActions({ runtime, CanvasError }),
open: async (ctx) => {
const { store, server } = await runtime(ctx);
const wanted = typeof ctx.input?.drawing === "string" ? ctx.input.drawing.trim() : "";
if (wanted) {
const current = store.drawingForInstance(ctx.instanceId);
let doc;
try {
doc = store.find(wanted) || store.create(wanted);
} catch (err) {
if (err instanceof StoreError) throw new CanvasError(err.code, err.message);
throw err;
}
if (!current || current.id !== doc.id) server.showDrawing(ctx.instanceId, doc);
} else {
server.ensureDrawing(ctx.instanceId);
}
return { title: "Draw", url: server.urlFor(ctx.instanceId) };
},
// Panels come and go, but the server and the instance's drawing binding stay,
// so reopening the same instance shows the same drawing.
onClose: async () => {
if (!runtimePromise) return;
try {
await (await runtimePromise).store.flush();
} catch (err) {
// The store keeps retrying failed saves, so closing a panel only needs to log it.
log(`could not save on close: ${err.message}`);
}
},
});
// Host startup can replace canvas registrations during the initial burst. Let that settle first.
try {
await new Promise((resolve) => setTimeout(resolve, 900));
session = await joinSession({ canvases: [canvas] });
} catch (error) {
const msg = error && error.message ? String(error.message) : String(error);
if (/session not found/i.test(msg)) process.exit(0);
throw error;
}
await session.log("Draw canvas ready.", { ephemeral: true });