mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 04:16:25 +00:00
diffs plugin
This commit is contained in:
124
extensions/diffs/README.md
Normal file
124
extensions/diffs/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# @openclaw/diffs
|
||||
|
||||
Read-only diff viewer plugin for **OpenClaw** agents.
|
||||
|
||||
It gives agents one tool, `diffs`, that can:
|
||||
|
||||
- render a gateway-hosted diff viewer for canvas use
|
||||
- render the same diff to a PNG image
|
||||
- accept either arbitrary `before`/`after` text or a unified patch
|
||||
|
||||
## What Agents Get
|
||||
|
||||
The tool can return:
|
||||
|
||||
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
|
||||
- `details.imagePath`: a local PNG artifact when image rendering is requested
|
||||
|
||||
This means an agent can:
|
||||
|
||||
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
|
||||
- call `diffs` with `mode=image`, then send the PNG through the normal `message` tool using `path` or `filePath`
|
||||
- call `diffs` with `mode=both` when it wants both outputs
|
||||
|
||||
## Tool Inputs
|
||||
|
||||
Before/after:
|
||||
|
||||
```json
|
||||
{
|
||||
"before": "# Hello\n\nOne",
|
||||
"after": "# Hello\n\nTwo",
|
||||
"path": "docs/example.md",
|
||||
"mode": "view"
|
||||
}
|
||||
```
|
||||
|
||||
Patch:
|
||||
|
||||
```json
|
||||
{
|
||||
"patch": "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-const x = 1;\n+const x = 2;\n",
|
||||
"mode": "both"
|
||||
}
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
- `mode`: `view`, `image`, or `both`
|
||||
- `layout`: `unified` or `split`
|
||||
- `theme`: `light` or `dark` (default: `dark`)
|
||||
- `expandUnchanged`: expand unchanged sections
|
||||
- `path`: display name for before/after input
|
||||
- `title`: explicit viewer title
|
||||
- `ttlSeconds`: artifact lifetime
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link
|
||||
|
||||
## Example Agent Prompts
|
||||
|
||||
Open in canvas:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `view` mode for this before/after content, then open the returned viewer URL in the canvas.
|
||||
|
||||
Path: docs/example.md
|
||||
|
||||
Before:
|
||||
# Hello
|
||||
|
||||
This is version one.
|
||||
|
||||
After:
|
||||
# Hello
|
||||
|
||||
This is version two.
|
||||
```
|
||||
|
||||
Render a PNG:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `image` mode for this before/after input. After it returns `details.imagePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
|
||||
|
||||
Path: README.md
|
||||
|
||||
Before:
|
||||
OpenClaw supports plugins.
|
||||
|
||||
After:
|
||||
OpenClaw supports plugins and hosted diff views.
|
||||
```
|
||||
|
||||
Do both:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered PNG by passing `details.imagePath` to the `message` tool.
|
||||
|
||||
Path: src/demo.ts
|
||||
|
||||
Before:
|
||||
const status = "old";
|
||||
|
||||
After:
|
||||
const status = "new";
|
||||
```
|
||||
|
||||
Patch input:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool with this unified patch in `view` mode. After it returns the viewer URL, present it in the canvas.
|
||||
|
||||
diff --git a/src/example.ts b/src/example.ts
|
||||
--- a/src/example.ts
|
||||
+++ b/src/example.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
export function add(a: number, b: number) {
|
||||
- return a + b;
|
||||
+ return a + b + 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the local temp directory.
|
||||
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
|
||||
1309
extensions/diffs/assets/viewer-runtime.js
Normal file
1309
extensions/diffs/assets/viewer-runtime.js
Normal file
File diff suppressed because one or more lines are too long
43
extensions/diffs/index.test.ts
Normal file
43
extensions/diffs/index.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http handler, and prompt guidance hook", () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpHandler = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
registerTool,
|
||||
registerHook() {},
|
||||
registerHttpHandler,
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerCommand() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on,
|
||||
});
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
});
|
||||
});
|
||||
28
extensions/diffs/index.ts
Normal file
28
extensions/diffs/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
import { createDiffsHttpHandler } from "./src/http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./src/store.js";
|
||||
import { createDiffsTool } from "./src/tool.js";
|
||||
|
||||
const plugin = {
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Read-only diff viewer and PNG renderer for agents.",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
logger: api.logger,
|
||||
});
|
||||
|
||||
api.registerTool(createDiffsTool({ api, store }));
|
||||
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
10
extensions/diffs/openclaw.plugin.json
Normal file
10
extensions/diffs/openclaw.plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "diffs",
|
||||
"name": "Diffs",
|
||||
"description": "Read-only diff viewer and image renderer for agents.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
20
extensions/diffs/package.json
Normal file
20
extensions/diffs/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.2.27",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.0.11",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
261
extensions/diffs/src/browser.ts
Normal file
261
extensions/diffs/src/browser.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { chromium } from "playwright-core";
|
||||
import type { DiffTheme } from "./types.js";
|
||||
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
|
||||
|
||||
export type DiffScreenshotter = {
|
||||
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
|
||||
};
|
||||
|
||||
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
||||
private readonly config: OpenClawConfig;
|
||||
|
||||
constructor(params: { config: OpenClawConfig }) {
|
||||
this.config = params.config;
|
||||
}
|
||||
|
||||
async screenshotHtml(params: {
|
||||
html: string;
|
||||
outputPath: string;
|
||||
theme: DiffTheme;
|
||||
}): Promise<string> {
|
||||
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
|
||||
const executablePath = await resolveBrowserExecutablePath(this.config);
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: ["--disable-dev-shm-usage", "--disable-gpu"],
|
||||
});
|
||||
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
colorScheme: params.theme,
|
||||
});
|
||||
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const asset = await getServedViewerAsset(pathname);
|
||||
if (!asset) {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: asset.contentType,
|
||||
body: asset.body,
|
||||
});
|
||||
});
|
||||
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
if (document.documentElement.dataset.openclawDiffsReady === "true") {
|
||||
return true;
|
||||
}
|
||||
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
|
||||
return (
|
||||
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const frame = document.querySelector(".oc-frame");
|
||||
if (frame instanceof HTMLElement) {
|
||||
frame.dataset.renderMode = "image";
|
||||
}
|
||||
});
|
||||
|
||||
const frame = page.locator(".oc-frame");
|
||||
await frame.waitFor();
|
||||
const initialBox = await frame.boundingBox();
|
||||
if (!initialBox) {
|
||||
throw new Error("Diff frame did not render.");
|
||||
}
|
||||
|
||||
const padding = 20;
|
||||
const clipWidth = Math.ceil(initialBox.width + padding * 2);
|
||||
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
|
||||
await page.setViewportSize({
|
||||
width: Math.max(clipWidth + padding, 900),
|
||||
height: Math.max(clipHeight + padding, 700),
|
||||
});
|
||||
|
||||
const box = await frame.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Diff frame was lost after resizing.");
|
||||
}
|
||||
|
||||
await page.screenshot({
|
||||
path: params.outputPath,
|
||||
type: "png",
|
||||
clip: {
|
||||
x: Math.max(box.x - padding, 0),
|
||||
y: Math.max(box.y - padding, 0),
|
||||
width: clipWidth,
|
||||
height: clipHeight,
|
||||
},
|
||||
});
|
||||
return params.outputPath;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Diff image rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
|
||||
);
|
||||
} finally {
|
||||
await browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function injectBaseHref(html: string): string {
|
||||
if (html.includes("<base ")) {
|
||||
return html;
|
||||
}
|
||||
return html.replace("<head>", '<head><base href="http://127.0.0.1/" />');
|
||||
}
|
||||
|
||||
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
|
||||
const configPath = config.browser?.executablePath?.trim();
|
||||
if (configPath) {
|
||||
await assertExecutable(configPath, "browser.executablePath");
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const envCandidates = [
|
||||
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
|
||||
process.env.BROWSER_EXECUTABLE_PATH,
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
for (const candidate of envCandidates) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of await collectExecutableCandidates()) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function collectExecutableCandidates(): Promise<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
for (const command of pathCommandsForPlatform()) {
|
||||
const resolved = await findExecutableInPath(command);
|
||||
if (resolved) {
|
||||
candidates.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of commonExecutablePathsForPlatform()) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function pathCommandsForPlatform(): string[] {
|
||||
if (process.platform === "win32") {
|
||||
return ["chrome.exe", "msedge.exe", "brave.exe"];
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
|
||||
}
|
||||
return [
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"msedge",
|
||||
"brave-browser",
|
||||
"brave",
|
||||
];
|
||||
}
|
||||
|
||||
function commonExecutablePathsForPlatform(): string[] {
|
||||
if (process.platform === "darwin") {
|
||||
return [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
];
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
return [
|
||||
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/msedge",
|
||||
"/usr/bin/brave-browser",
|
||||
"/snap/bin/chromium",
|
||||
];
|
||||
}
|
||||
|
||||
async function findExecutableInPath(command: string): Promise<string | undefined> {
|
||||
const pathValue = process.env.PATH;
|
||||
if (!pathValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(directory, command);
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function assertExecutable(candidate: string, label: string): Promise<void> {
|
||||
if (!(await isExecutable(candidate))) {
|
||||
throw new Error(`${label} not found or not executable: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isExecutable(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
115
extensions/diffs/src/http.test.ts
Normal file
115
extensions/diffs/src/http.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
});
|
||||
136
extensions/diffs/src/http.ts
Normal file
136
extensions/diffs/src/http.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk";
|
||||
import type { DiffArtifactStore } from "./store.js";
|
||||
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
|
||||
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
|
||||
|
||||
const VIEW_PREFIX = "/plugins/diffs/view/";
|
||||
const VIEWER_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
|
||||
export function createDiffsHttpHandler(params: {
|
||||
store: DiffArtifactStore;
|
||||
logger?: PluginLogger;
|
||||
}) {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const parsed = parseRequestUrl(req.url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
|
||||
return await serveAsset(req, res, parsed.pathname, params.logger);
|
||||
}
|
||||
|
||||
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
respondText(res, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean);
|
||||
const id = pathParts[3];
|
||||
const token = pathParts[4];
|
||||
if (
|
||||
!id ||
|
||||
!token ||
|
||||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
|
||||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
|
||||
) {
|
||||
respondText(res, 404, "Diff not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const artifact = await params.store.getArtifact(id, token);
|
||||
if (!artifact) {
|
||||
respondText(res, 404, "Diff not found or expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await params.store.readHtml(id);
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, "text/html; charset=utf-8");
|
||||
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(html);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
|
||||
respondText(res, 500, "Failed to load diff");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseRequestUrl(rawUrl?: string): URL | null {
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(rawUrl, "http://127.0.0.1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serveAsset(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
logger?: PluginLogger,
|
||||
): Promise<boolean> {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
respondText(res, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = await getServedViewerAsset(pathname);
|
||||
if (!asset) {
|
||||
respondText(res, 404, "Asset not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, asset.contentType);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(asset.body);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
|
||||
respondText(res, 500, "Failed to load asset");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function respondText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.statusCode = statusCode;
|
||||
setSharedHeaders(res, "text/plain; charset=utf-8");
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function setSharedHeaders(res: ServerResponse, contentType: string): void {
|
||||
res.setHeader("cache-control", "no-store, max-age=0");
|
||||
res.setHeader("content-type", contentType);
|
||||
res.setHeader("x-content-type-options", "nosniff");
|
||||
res.setHeader("referrer-policy", "no-referrer");
|
||||
}
|
||||
9
extensions/diffs/src/prompt-guidance.ts
Normal file
9
extensions/diffs/src/prompt-guidance.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const DIFFS_AGENT_GUIDANCE = [
|
||||
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
|
||||
"The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.",
|
||||
"Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.",
|
||||
"Use `mode=image` when you need a rendered PNG. The tool result includes `details.imagePath` for the generated file.",
|
||||
"When you need to deliver the PNG to a user or channel, do not rely on the raw tool-result image renderer. Instead, call the `message` tool and pass `details.imagePath` through `path` or `filePath`.",
|
||||
"Use `mode=both` when you want both the gateway viewer URL and the PNG artifact.",
|
||||
"Good defaults: `theme=dark` for canvas rendering, `layout=unified` for most diffs, and include `path` for before/after text when you know the file name.",
|
||||
].join("\n");
|
||||
61
extensions/diffs/src/render.test.ts
Normal file
61
extensions/diffs/src/render.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
layout: "unified",
|
||||
expandUnchanged: false,
|
||||
theme: "light",
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
layout: "split",
|
||||
expandUnchanged: true,
|
||||
theme: "dark",
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
});
|
||||
});
|
||||
351
extensions/diffs/src/render.ts
Normal file
351
extensions/diffs/src/render.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
|
||||
import type {
|
||||
DiffInput,
|
||||
DiffRenderOptions,
|
||||
DiffViewerOptions,
|
||||
DiffViewerPayload,
|
||||
RenderedDiffDocument,
|
||||
} from "./types.js";
|
||||
import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
|
||||
|
||||
const DEFAULT_FILE_NAME = "diff.txt";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeJsonScript(value: unknown): string {
|
||||
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
||||
}
|
||||
|
||||
function buildDiffTitle(input: DiffInput): string {
|
||||
if (input.title?.trim()) {
|
||||
return input.title.trim();
|
||||
}
|
||||
if (input.kind === "before_after") {
|
||||
return input.path?.trim() || "Text diff";
|
||||
}
|
||||
return "Patch diff";
|
||||
}
|
||||
|
||||
function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_after" }>): string {
|
||||
if (input.path?.trim()) {
|
||||
return input.path.trim();
|
||||
}
|
||||
if (input.lang?.trim()) {
|
||||
return `diff.${input.lang.trim().replace(/^\.+/, "")}`;
|
||||
}
|
||||
return DEFAULT_FILE_NAME;
|
||||
}
|
||||
|
||||
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
|
||||
return {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: options.layout,
|
||||
expandUnchanged: options.expandUnchanged,
|
||||
themeType: options.theme,
|
||||
overflow: "wrap" as const,
|
||||
unsafeCSS: `
|
||||
:host {
|
||||
--diffs-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--diffs-header-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--diffs-font-size: 15px;
|
||||
--diffs-line-height: 24px;
|
||||
}
|
||||
|
||||
[data-diffs-header] {
|
||||
min-height: 64px;
|
||||
padding-inline: 18px 14px;
|
||||
}
|
||||
|
||||
[data-header-content] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-metadata] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-inline-start: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
transition: opacity 120ms ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button[data-active="true"] {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? (normalized as SupportedLanguages) : undefined;
|
||||
}
|
||||
|
||||
function buildPayloadLanguages(payload: {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
}): SupportedLanguages[] {
|
||||
const langs = new Set<SupportedLanguages>();
|
||||
if (payload.fileDiff?.lang) {
|
||||
langs.add(payload.fileDiff.lang);
|
||||
}
|
||||
if (payload.oldFile?.lang) {
|
||||
langs.add(payload.oldFile.lang);
|
||||
}
|
||||
if (payload.newFile?.lang) {
|
||||
langs.add(payload.newFile.lang);
|
||||
}
|
||||
if (langs.size === 0) {
|
||||
langs.add("text");
|
||||
}
|
||||
return [...langs];
|
||||
}
|
||||
|
||||
function renderDiffCard(payload: DiffViewerPayload): string {
|
||||
return `<section class="oc-diff-card">
|
||||
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
|
||||
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
|
||||
</diffs-container>
|
||||
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(params: {
|
||||
title: string;
|
||||
bodyHtml: string;
|
||||
theme: DiffRenderOptions["theme"];
|
||||
}): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>${escapeHtml(params.title)}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 22px;
|
||||
font-family:
|
||||
"Fira Code",
|
||||
"SF Mono",
|
||||
Monaco,
|
||||
Consolas,
|
||||
monospace;
|
||||
background: #05070b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
background: #f3f5f8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.oc-frame {
|
||||
max-width: 1560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.oc-frame[data-render-mode="image"] {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
[data-openclaw-diff-root] {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.oc-diff-card {
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .oc-diff-card {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.oc-diff-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oc-frame[data-render-mode="image"] .oc-diff-card {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-openclaw-diff-root] {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="${params.theme}">
|
||||
<main class="oc-frame" data-render-mode="viewer">
|
||||
<div data-openclaw-diff-root>
|
||||
${params.bodyHtml}
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="${VIEWER_LOADER_PATH}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function renderBeforeAfterDiff(
|
||||
input: Extract<DiffInput, { kind: "before_after" }>,
|
||||
options: DiffRenderOptions,
|
||||
): Promise<{ bodyHtml: string; fileCount: number }> {
|
||||
const fileName = resolveBeforeAfterFileName(input);
|
||||
const lang = normalizeSupportedLanguage(input.lang);
|
||||
const oldFile: FileContents = {
|
||||
name: fileName,
|
||||
contents: input.before,
|
||||
...(lang ? { lang } : {}),
|
||||
};
|
||||
const newFile: FileContents = {
|
||||
name: fileName,
|
||||
contents: input.after,
|
||||
...(lang ? { lang } : {}),
|
||||
};
|
||||
const payloadOptions = buildDiffOptions(options);
|
||||
const result = await preloadMultiFileDiff({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: payloadOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
bodyHtml: renderDiffCard({
|
||||
prerenderedHTML: result.prerenderedHTML,
|
||||
oldFile: result.oldFile,
|
||||
newFile: result.newFile,
|
||||
options: payloadOptions,
|
||||
langs: buildPayloadLanguages({ oldFile: result.oldFile, newFile: result.newFile }),
|
||||
}),
|
||||
fileCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPatchDiff(
|
||||
input: Extract<DiffInput, { kind: "patch" }>,
|
||||
options: DiffRenderOptions,
|
||||
): Promise<{ bodyHtml: string; fileCount: number }> {
|
||||
const files = parsePatchFiles(input.patch).flatMap((entry) => entry.files ?? []);
|
||||
if (files.length === 0) {
|
||||
throw new Error("Patch input did not contain any file diffs.");
|
||||
}
|
||||
|
||||
const payloadOptions = buildDiffOptions(options);
|
||||
const sections = await Promise.all(
|
||||
files.map(async (fileDiff) => {
|
||||
const result = await preloadFileDiff({
|
||||
fileDiff,
|
||||
options: payloadOptions,
|
||||
});
|
||||
|
||||
return renderDiffCard({
|
||||
prerenderedHTML: result.prerenderedHTML,
|
||||
fileDiff: result.fileDiff,
|
||||
options: payloadOptions,
|
||||
langs: buildPayloadLanguages({ fileDiff: result.fileDiff }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
bodyHtml: sections.join("\n"),
|
||||
fileCount: files.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderDiffDocument(
|
||||
input: DiffInput,
|
||||
options: DiffRenderOptions,
|
||||
): Promise<RenderedDiffDocument> {
|
||||
const title = buildDiffTitle(input);
|
||||
const rendered =
|
||||
input.kind === "before_after"
|
||||
? await renderBeforeAfterDiff(input, options)
|
||||
: await renderPatchDiff(input, options);
|
||||
|
||||
return {
|
||||
html: buildHtmlDocument({
|
||||
title,
|
||||
bodyHtml: rendered.bodyHtml,
|
||||
theme: options.theme,
|
||||
}),
|
||||
title,
|
||||
fileCount: rendered.fileCount,
|
||||
inputKind: input.kind,
|
||||
};
|
||||
}
|
||||
64
extensions/diffs/src/store.test.ts
Normal file
64
extensions/diffs/src/store.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
describe("DiffArtifactStore", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and retrieves an artifact", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
expect(loaded?.id).toBe(artifact.id);
|
||||
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
|
||||
});
|
||||
|
||||
it("expires artifacts after the ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "patch",
|
||||
fileCount: 2,
|
||||
ttlMs: 1_000,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the stored image path", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const imagePath = store.allocateImagePath(artifact.id);
|
||||
const updated = await store.updateImagePath(artifact.id, imagePath);
|
||||
expect(updated.imagePath).toBe(imagePath);
|
||||
});
|
||||
});
|
||||
183
extensions/diffs/src/store.ts
Normal file
183
extensions/diffs/src/store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk";
|
||||
import type { DiffArtifactMeta } from "./types.js";
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const VIEWER_PREFIX = "/plugins/diffs/view";
|
||||
|
||||
type CreateArtifactParams = {
|
||||
html: string;
|
||||
title: string;
|
||||
inputKind: DiffArtifactMeta["inputKind"];
|
||||
fileCount: number;
|
||||
ttlMs?: number;
|
||||
};
|
||||
|
||||
export class DiffArtifactStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly logger?: PluginLogger;
|
||||
|
||||
constructor(params: { rootDir: string; logger?: PluginLogger }) {
|
||||
this.rootDir = params.rootDir;
|
||||
this.logger = params.logger;
|
||||
}
|
||||
|
||||
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
|
||||
await this.ensureRoot();
|
||||
await this.cleanupExpired();
|
||||
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const artifactDir = this.artifactDir(id);
|
||||
const htmlPath = path.join(artifactDir, "viewer.html");
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
||||
const meta: DiffArtifactMeta = {
|
||||
id,
|
||||
token,
|
||||
title: params.title,
|
||||
inputKind: params.inputKind,
|
||||
fileCount: params.fileCount,
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
|
||||
htmlPath,
|
||||
};
|
||||
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await fs.writeFile(htmlPath, params.html, "utf8");
|
||||
await this.writeMeta(meta);
|
||||
return meta;
|
||||
}
|
||||
|
||||
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
if (meta.token !== token) {
|
||||
return null;
|
||||
}
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
return null;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async readHtml(id: string): Promise<string> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
return await fs.readFile(meta.htmlPath, "utf8");
|
||||
}
|
||||
|
||||
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
const next: DiffArtifactMeta = {
|
||||
...meta,
|
||||
imagePath,
|
||||
};
|
||||
await this.writeMeta(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
allocateImagePath(id: string): string {
|
||||
return path.join(this.artifactDir(id), "preview.png");
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
await this.ensureRoot();
|
||||
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
|
||||
const now = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const id = entry.name;
|
||||
const meta = await this.readMeta(id);
|
||||
if (meta) {
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactPath = this.artifactDir(id);
|
||||
const stat = await fs.stat(artifactPath).catch(() => null);
|
||||
if (!stat) {
|
||||
return;
|
||||
}
|
||||
if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
private artifactDir(id: string): string {
|
||||
return path.join(this.rootDir, id);
|
||||
}
|
||||
|
||||
private metaPath(id: string): string {
|
||||
return path.join(this.artifactDir(id), "meta.json");
|
||||
}
|
||||
|
||||
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
|
||||
await fs.writeFile(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf8");
|
||||
}
|
||||
|
||||
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.metaPath(id), "utf8");
|
||||
return JSON.parse(raw) as DiffArtifactMeta;
|
||||
} catch (error) {
|
||||
if (isFileNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
this.logger?.warn(`Failed to read diff artifact metadata for ${id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteArtifact(id: string): Promise<void> {
|
||||
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtlMs(value?: number): number {
|
||||
if (!Number.isFinite(value) || value === undefined) {
|
||||
return DEFAULT_TTL_MS;
|
||||
}
|
||||
const rounded = Math.floor(value);
|
||||
if (rounded <= 0) {
|
||||
return DEFAULT_TTL_MS;
|
||||
}
|
||||
return Math.min(rounded, MAX_TTL_MS);
|
||||
}
|
||||
|
||||
function isExpired(meta: DiffArtifactMeta): boolean {
|
||||
const expiresAt = Date.parse(meta.expiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() >= expiresAt;
|
||||
}
|
||||
|
||||
function isFileNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
147
extensions/diffs/src/tool.test.ts
Normal file
147
extensions/diffs/src/tool.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffsTool } from "./tool.js";
|
||||
|
||||
describe("diffs tool", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns a viewer URL in view mode", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
});
|
||||
|
||||
const text = readTextContent(result, 0);
|
||||
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns an image artifact in image mode", async () => {
|
||||
const screenshotter = {
|
||||
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, Buffer.from("png"));
|
||||
return outputPath;
|
||||
}),
|
||||
};
|
||||
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
screenshotter,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-2", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "image",
|
||||
});
|
||||
|
||||
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
||||
expect(readTextContent(result, 0)).toContain("Diff image generated at:");
|
||||
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to view output when both mode cannot render an image", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
screenshotter: {
|
||||
screenshotHtml: vi.fn(async () => {
|
||||
throw new Error("browser missing");
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-3", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "both",
|
||||
});
|
||||
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect(readTextContent(result, 0)).toContain("Image rendering failed");
|
||||
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
|
||||
});
|
||||
|
||||
it("rejects invalid base URLs as tool input errors", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute?.("tool-4", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "view",
|
||||
baseUrl: "javascript:alert(1)",
|
||||
}),
|
||||
).rejects.toThrow("Invalid baseUrl");
|
||||
});
|
||||
});
|
||||
|
||||
function createApi(): OpenClawPluginApi {
|
||||
return {
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpHandler() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerCommand() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
}
|
||||
|
||||
function readTextContent(result: unknown, index: number): string {
|
||||
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
|
||||
?.content;
|
||||
const entry = content?.[index];
|
||||
return entry?.type === "text" ? (entry.text ?? "") : "";
|
||||
}
|
||||
245
extensions/diffs/src/tool.ts
Normal file
245
extensions/diffs/src/tool.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import type { DiffArtifactStore } from "./store.js";
|
||||
import {
|
||||
DIFF_LAYOUTS,
|
||||
DIFF_MODES,
|
||||
DIFF_THEMES,
|
||||
type DiffInput,
|
||||
type DiffLayout,
|
||||
type DiffMode,
|
||||
type DiffTheme,
|
||||
} from "./types.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
function stringEnum<T extends readonly string[]>(values: T, description: string) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
const DiffsToolSchema = Type.Object(
|
||||
{
|
||||
before: Type.Optional(Type.String({ description: "Original text content." })),
|
||||
after: Type.Optional(Type.String({ description: "Updated text content." })),
|
||||
patch: Type.Optional(Type.String({ description: "Unified diff or patch text." })),
|
||||
path: Type.Optional(Type.String({ description: "Display path for before/after input." })),
|
||||
lang: Type.Optional(
|
||||
Type.String({ description: "Optional language override for before/after input." }),
|
||||
),
|
||||
title: Type.Optional(Type.String({ description: "Optional title for the rendered diff." })),
|
||||
mode: Type.Optional(
|
||||
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
|
||||
),
|
||||
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
|
||||
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
|
||||
expandUnchanged: Type.Optional(
|
||||
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
|
||||
),
|
||||
ttlSeconds: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.",
|
||||
minimum: 1,
|
||||
maximum: 21_600,
|
||||
}),
|
||||
),
|
||||
baseUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Optional gateway base URL override used when building the viewer URL, for example https://gateway.example.com.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type DiffsToolParams = Static<typeof DiffsToolSchema>;
|
||||
|
||||
export function createDiffsTool(params: {
|
||||
api: OpenClawPluginApi;
|
||||
store: DiffArtifactStore;
|
||||
screenshotter?: DiffScreenshotter;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
name: "diffs",
|
||||
label: "Diffs",
|
||||
description:
|
||||
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG.",
|
||||
parameters: DiffsToolSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
const toolParams = rawParams as DiffsToolParams;
|
||||
const input = normalizeDiffInput(toolParams);
|
||||
const mode = normalizeMode(toolParams.mode);
|
||||
const theme = normalizeTheme(toolParams.theme);
|
||||
const layout = normalizeLayout(toolParams.layout);
|
||||
const expandUnchanged = toolParams.expandUnchanged === true;
|
||||
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
|
||||
|
||||
const rendered = await renderDiffDocument(input, {
|
||||
layout,
|
||||
expandUnchanged,
|
||||
theme,
|
||||
});
|
||||
|
||||
const artifact = await params.store.createArtifact({
|
||||
html: rendered.html,
|
||||
title: rendered.title,
|
||||
inputKind: rendered.inputKind,
|
||||
fileCount: rendered.fileCount,
|
||||
ttlMs,
|
||||
});
|
||||
|
||||
const viewerUrl = buildViewerUrl({
|
||||
config: params.api.config,
|
||||
viewerPath: artifact.viewerPath,
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl),
|
||||
});
|
||||
|
||||
const baseDetails = {
|
||||
artifactId: artifact.id,
|
||||
viewerUrl,
|
||||
viewerPath: artifact.viewerPath,
|
||||
title: artifact.title,
|
||||
expiresAt: artifact.expiresAt,
|
||||
inputKind: artifact.inputKind,
|
||||
fileCount: artifact.fileCount,
|
||||
mode,
|
||||
};
|
||||
|
||||
if (mode === "view") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diff viewer ready.\n${viewerUrl}`,
|
||||
},
|
||||
],
|
||||
details: baseDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const screenshotter =
|
||||
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
|
||||
|
||||
try {
|
||||
const imagePath = params.store.allocateImagePath(artifact.id);
|
||||
await screenshotter.screenshotHtml({
|
||||
html: rendered.html,
|
||||
outputPath: imagePath,
|
||||
theme,
|
||||
});
|
||||
await params.store.updateImagePath(artifact.id, imagePath);
|
||||
const imageStats = await fs.stat(imagePath);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Diff viewer: ${viewerUrl}\n` +
|
||||
`Diff image generated at: ${imagePath}\n` +
|
||||
"Use the `message` tool with `path` or `filePath` to send the PNG.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
...baseDetails,
|
||||
imagePath,
|
||||
path: imagePath,
|
||||
imageBytes: imageStats.size,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (mode === "both") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Diff viewer ready.\n${viewerUrl}\n` +
|
||||
`Image rendering failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
...baseDetails,
|
||||
imageError: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
|
||||
const patch = params.patch?.trim();
|
||||
const before = params.before;
|
||||
const after = params.after;
|
||||
|
||||
if (patch) {
|
||||
if (before !== undefined || after !== undefined) {
|
||||
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
|
||||
}
|
||||
return {
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: params.title?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (before === undefined || after === undefined) {
|
||||
throw new PluginToolInputError("Provide patch or both before and after text.");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "before_after",
|
||||
before,
|
||||
after,
|
||||
path: params.path?.trim() || undefined,
|
||||
lang: params.lang?.trim() || undefined,
|
||||
title: params.title?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string): string | undefined {
|
||||
const normalized = baseUrl?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeViewerBaseUrl(normalized);
|
||||
} catch {
|
||||
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMode(mode?: DiffMode): DiffMode {
|
||||
return mode && DIFF_MODES.includes(mode) ? mode : "both";
|
||||
}
|
||||
|
||||
function normalizeTheme(theme?: DiffTheme): DiffTheme {
|
||||
return theme && DIFF_THEMES.includes(theme) ? theme : "dark";
|
||||
}
|
||||
|
||||
function normalizeLayout(layout?: DiffLayout): DiffLayout {
|
||||
return layout && DIFF_LAYOUTS.includes(layout) ? layout : "unified";
|
||||
}
|
||||
|
||||
function normalizeTtlMs(ttlSeconds?: number): number | undefined {
|
||||
if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
class PluginToolInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
76
extensions/diffs/src/types.ts
Normal file
76
extensions/diffs/src/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
|
||||
export const DIFF_LAYOUTS = ["unified", "split"] as const;
|
||||
export const DIFF_MODES = ["view", "image", "both"] as const;
|
||||
export const DIFF_THEMES = ["light", "dark"] as const;
|
||||
|
||||
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
|
||||
export type DiffMode = (typeof DIFF_MODES)[number];
|
||||
export type DiffTheme = (typeof DIFF_THEMES)[number];
|
||||
|
||||
export type BeforeAfterDiffInput = {
|
||||
kind: "before_after";
|
||||
before: string;
|
||||
after: string;
|
||||
path?: string;
|
||||
lang?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type PatchDiffInput = {
|
||||
kind: "patch";
|
||||
patch: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
|
||||
|
||||
export type DiffRenderOptions = {
|
||||
layout: DiffLayout;
|
||||
expandUnchanged: boolean;
|
||||
theme: DiffTheme;
|
||||
};
|
||||
|
||||
export type DiffViewerOptions = {
|
||||
theme: {
|
||||
light: "pierre-light";
|
||||
dark: "pierre-dark";
|
||||
};
|
||||
diffStyle: DiffLayout;
|
||||
expandUnchanged: boolean;
|
||||
themeType: DiffTheme;
|
||||
overflow: "scroll" | "wrap";
|
||||
unsafeCSS: string;
|
||||
};
|
||||
|
||||
export type DiffViewerPayload = {
|
||||
prerenderedHTML: string;
|
||||
options: DiffViewerOptions;
|
||||
langs: SupportedLanguages[];
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
fileDiff?: FileDiffMetadata;
|
||||
};
|
||||
|
||||
export type RenderedDiffDocument = {
|
||||
html: string;
|
||||
title: string;
|
||||
fileCount: number;
|
||||
inputKind: DiffInput["kind"];
|
||||
};
|
||||
|
||||
export type DiffArtifactMeta = {
|
||||
id: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
title: string;
|
||||
inputKind: DiffInput["kind"];
|
||||
fileCount: number;
|
||||
viewerPath: string;
|
||||
htmlPath: string;
|
||||
imagePath?: string;
|
||||
};
|
||||
|
||||
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
|
||||
export const DIFF_ARTIFACT_TOKEN_PATTERN = /^[0-9a-f]{48}$/;
|
||||
120
extensions/diffs/src/url.ts
Normal file
120
extensions/diffs/src/url.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
export function buildViewerUrl(params: {
|
||||
config: OpenClawConfig;
|
||||
viewerPath: string;
|
||||
baseUrl?: string;
|
||||
}): string {
|
||||
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
|
||||
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
|
||||
const normalizedPath = params.viewerPath.startsWith("/")
|
||||
? params.viewerPath
|
||||
: `/${params.viewerPath}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function normalizeViewerBaseUrl(raw: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid baseUrl: ${raw}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`baseUrl must use http or https: ${raw}`);
|
||||
}
|
||||
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash;
|
||||
}
|
||||
|
||||
function resolveGatewayBaseUrl(config: OpenClawConfig): string {
|
||||
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
|
||||
const port =
|
||||
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
|
||||
const bind = config.gateway?.bind ?? "loopback";
|
||||
|
||||
if (bind === "custom" && config.gateway?.customBindHost?.trim()) {
|
||||
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`;
|
||||
}
|
||||
|
||||
if (bind === "lan") {
|
||||
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
|
||||
}
|
||||
|
||||
if (bind === "tailnet") {
|
||||
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
|
||||
}
|
||||
|
||||
return `${scheme}://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function pickPrimaryLanIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
const preferredNames = ["en0", "eth0"];
|
||||
|
||||
for (const name of preferredNames) {
|
||||
const candidate = pickPrivateAddress(nets[name]);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entries of Object.values(nets)) {
|
||||
const candidate = pickPrivateAddress(entries);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickPrimaryTailnetIPv4(): string | undefined {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const entries of Object.values(nets)) {
|
||||
const candidate = entries?.find((entry) => isTailnetIPv4(entry.address) && !entry.internal);
|
||||
if (candidate?.address) {
|
||||
return candidate.address;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickPrivateAddress(entries: os.NetworkInterfaceInfo[] | undefined): string | undefined {
|
||||
return entries?.find(
|
||||
(entry) => entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address),
|
||||
)?.address;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
const octets = parseIpv4(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
}
|
||||
|
||||
function isTailnetIPv4(address: string): boolean {
|
||||
const octets = parseIpv4(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 100 && b >= 64 && b <= 127;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): number[] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
const octets = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return octets;
|
||||
}
|
||||
22
extensions/diffs/src/viewer-assets.test.ts
Normal file
22
extensions/diffs/src/viewer-assets.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
62
extensions/diffs/src/viewer-assets.ts
Normal file
62
extensions/diffs/src/viewer-assets.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
|
||||
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
|
||||
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
|
||||
|
||||
const VIEWER_RUNTIME_FILE_URL = new URL("../assets/viewer-runtime.js", import.meta.url);
|
||||
|
||||
export type ServedViewerAsset = {
|
||||
body: string | Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
type RuntimeAssetCache = {
|
||||
mtimeMs: number;
|
||||
runtimeBody: Buffer;
|
||||
loaderBody: string;
|
||||
};
|
||||
|
||||
let runtimeAssetCache: RuntimeAssetCache | null = null;
|
||||
|
||||
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
|
||||
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assets = await loadViewerAssets();
|
||||
if (pathname === VIEWER_LOADER_PATH) {
|
||||
return {
|
||||
body: assets.loaderBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === VIEWER_RUNTIME_PATH) {
|
||||
return {
|
||||
body: assets.runtimeBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
|
||||
const runtimePath = fileURLToPath(VIEWER_RUNTIME_FILE_URL);
|
||||
const runtimeStat = await fs.stat(runtimePath);
|
||||
if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) {
|
||||
return runtimeAssetCache;
|
||||
}
|
||||
|
||||
const runtimeBody = await fs.readFile(runtimePath);
|
||||
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
|
||||
runtimeAssetCache = {
|
||||
mtimeMs: runtimeStat.mtimeMs,
|
||||
runtimeBody,
|
||||
loaderBody: `import "${VIEWER_RUNTIME_PATH}?v=${hash}";\n`,
|
||||
};
|
||||
return runtimeAssetCache;
|
||||
}
|
||||
295
extensions/diffs/src/viewer-client.ts
Normal file
295
extensions/diffs/src/viewer-client.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { FileDiff, preloadHighlighter } from "@pierre/diffs";
|
||||
import type {
|
||||
FileContents,
|
||||
FileDiffMetadata,
|
||||
FileDiffOptions,
|
||||
SupportedLanguages,
|
||||
} from "@pierre/diffs";
|
||||
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
|
||||
|
||||
type ViewerState = {
|
||||
theme: DiffTheme;
|
||||
layout: DiffLayout;
|
||||
backgroundEnabled: boolean;
|
||||
wrapEnabled: boolean;
|
||||
};
|
||||
|
||||
type DiffController = {
|
||||
payload: DiffViewerPayload;
|
||||
diff: FileDiff;
|
||||
};
|
||||
|
||||
const controllers: DiffController[] = [];
|
||||
|
||||
const viewerState: ViewerState = {
|
||||
theme: "dark",
|
||||
layout: "unified",
|
||||
backgroundEnabled: true,
|
||||
wrapEnabled: true,
|
||||
};
|
||||
|
||||
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
|
||||
const raw = element.textContent?.trim();
|
||||
if (!raw) {
|
||||
throw new Error("Diff payload was empty.");
|
||||
}
|
||||
return JSON.parse(raw) as DiffViewerPayload;
|
||||
}
|
||||
|
||||
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
|
||||
return [...document.querySelectorAll<HTMLElement>(".oc-diff-card")].flatMap((card) => {
|
||||
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
|
||||
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
|
||||
if (!host || !payloadNode) {
|
||||
return [];
|
||||
}
|
||||
return [{ host, payload: parsePayload(payloadNode) }];
|
||||
});
|
||||
}
|
||||
|
||||
function ensureShadowRoot(host: HTMLElement): void {
|
||||
if (host.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
const template = host.querySelector<HTMLTemplateElement>(
|
||||
":scope > template[shadowrootmode='open']",
|
||||
);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const shadowRoot = host.attachShadow({ mode: "open" });
|
||||
shadowRoot.append(template.content.cloneNode(true));
|
||||
template.remove();
|
||||
}
|
||||
|
||||
function getHydrateProps(payload: DiffViewerPayload): {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
} {
|
||||
if (payload.fileDiff) {
|
||||
return { fileDiff: payload.fileDiff };
|
||||
}
|
||||
return {
|
||||
oldFile: payload.oldFile,
|
||||
newFile: payload.newFile,
|
||||
};
|
||||
}
|
||||
|
||||
function createToolbarButton(params: {
|
||||
title: string;
|
||||
active: boolean;
|
||||
iconMarkup: string;
|
||||
onClick: () => void;
|
||||
}): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "oc-diff-toolbar-button";
|
||||
button.dataset.active = String(params.active);
|
||||
button.title = params.title;
|
||||
button.setAttribute("aria-label", params.title);
|
||||
button.innerHTML = params.iconMarkup;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
params.onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function splitIcon(): string {
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0"></path>
|
||||
<path fill="currentColor" opacity="0.35" d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"></path>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function unifiedIcon(): string {
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" fill-rule="evenodd" opacity="0.4" d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function wrapIcon(active: boolean): string {
|
||||
if (active) {
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.88" d="M2 4.25h8.25a2.75 2.75 0 1 1 0 5.5H7.5a.75.75 0 0 0 0 1.5h3.1l-1.07 1.06a.75.75 0 1 0 1.06 1.06l2.35-2.34a.75.75 0 0 0 0-1.06l-2.35-2.34a.75.75 0 1 0-1.06 1.06l1.07 1.06H10.25a1.25 1.25 0 1 0 0-2.5H2z"></path>
|
||||
<rect x="2" y="11.75" width="4.75" height="1.5" rx=".75" fill="currentColor" opacity="0.55"></rect>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="2" y="4" width="12" height="1.5" rx=".75" fill="currentColor"></rect>
|
||||
<rect x="2" y="7.25" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.82"></rect>
|
||||
<rect x="2" y="10.5" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.64"></rect>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function backgroundIcon(active: boolean): string {
|
||||
if (active) {
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.4" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" opacity="0.4" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.22" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
|
||||
<path fill="currentColor" opacity="0.22" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" opacity="0.22" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
|
||||
<path d="M2.5 13.5 13.5 2.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"></path>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function themeIcon(theme: DiffTheme): string {
|
||||
if (theme === "dark") {
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10.794 3.647a.217.217 0 0 1 .412 0l.387 1.162c.173.518.58.923 1.097 1.096l1.162.388a.217.217 0 0 1 0 .412l-1.162.386a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.74 1.74 0 0 0 9.31 7.092l-1.162-.386a.217.217 0 0 1 0-.412l1.162-.388a1.73 1.73 0 0 0 1.097-1.096zM13.863.598a.144.144 0 0 1 .221-.071.14.14 0 0 1 .053.07l.258.775c.115.345.386.616.732.731l.774.258a.145.145 0 0 1 0 .274l-.774.259a1.16 1.16 0 0 0-.732.732l-.258.773a.145.145 0 0 1-.274 0l-.258-.773a1.16 1.16 0 0 0-.732-.732l-.774-.259a.145.145 0 0 1 0-.273l.774-.259c.346-.115.617-.386.732-.732z"></path>
|
||||
<path fill="currentColor" d="M6.25 1.742a.67.67 0 0 1 .07.75 6.3 6.3 0 0 0-.768 3.028c0 2.746 1.746 5.084 4.193 5.979H1.774A7.2 7.2 0 0 1 1 8.245c0-3.013 1.85-5.598 4.484-6.694a.66.66 0 0 1 .766.19M.75 12.499a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
|
||||
</svg>`;
|
||||
}
|
||||
return `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M6.856.764a.75.75 0 0 1 .094 1.035A5.75 5.75 0 0 0 13.81 10.95a.75.75 0 1 1 1.13.99A7.251 7.251 0 1 1 6.762.858a.75.75 0 0 1 .094-.094"></path>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function createToolbar(): HTMLElement {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "oc-diff-toolbar";
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.layout === "unified" ? "Switch to split diff" : "Switch to unified diff",
|
||||
active: viewerState.layout === "split",
|
||||
iconMarkup: viewerState.layout === "split" ? splitIcon() : unifiedIcon(),
|
||||
onClick: () => {
|
||||
viewerState.layout = viewerState.layout === "unified" ? "split" : "unified";
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.wrapEnabled ? "Disable word wrap" : "Enable word wrap",
|
||||
active: viewerState.wrapEnabled,
|
||||
iconMarkup: wrapIcon(viewerState.wrapEnabled),
|
||||
onClick: () => {
|
||||
viewerState.wrapEnabled = !viewerState.wrapEnabled;
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.backgroundEnabled
|
||||
? "Hide background highlights"
|
||||
: "Show background highlights",
|
||||
active: viewerState.backgroundEnabled,
|
||||
iconMarkup: backgroundIcon(viewerState.backgroundEnabled),
|
||||
onClick: () => {
|
||||
viewerState.backgroundEnabled = !viewerState.backgroundEnabled;
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.theme === "dark" ? "Switch to light theme" : "Switch to dark theme",
|
||||
active: viewerState.theme === "dark",
|
||||
iconMarkup: themeIcon(viewerState.theme),
|
||||
onClick: () => {
|
||||
viewerState.theme = viewerState.theme === "dark" ? "light" : "dark";
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefined> {
|
||||
return {
|
||||
theme: payload.options.theme,
|
||||
themeType: viewerState.theme,
|
||||
diffStyle: viewerState.layout,
|
||||
expandUnchanged: payload.options.expandUnchanged,
|
||||
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
|
||||
disableBackground: !viewerState.backgroundEnabled,
|
||||
unsafeCSS: payload.options.unsafeCSS,
|
||||
renderHeaderMetadata: () => createToolbar(),
|
||||
};
|
||||
}
|
||||
|
||||
function syncDocumentTheme(): void {
|
||||
document.body.dataset.theme = viewerState.theme;
|
||||
}
|
||||
|
||||
function applyState(controller: DiffController): void {
|
||||
controller.diff.setOptions(createRenderOptions(controller.payload));
|
||||
controller.diff.rerender();
|
||||
}
|
||||
|
||||
function syncAllControllers(): void {
|
||||
syncDocumentTheme();
|
||||
for (const controller of controllers) {
|
||||
applyState(controller);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateViewer(): Promise<void> {
|
||||
const cards = getCards();
|
||||
const langs = new Set<SupportedLanguages>();
|
||||
const firstPayload = cards[0]?.payload;
|
||||
|
||||
if (firstPayload) {
|
||||
viewerState.theme = firstPayload.options.themeType;
|
||||
viewerState.layout = firstPayload.options.diffStyle;
|
||||
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
|
||||
}
|
||||
|
||||
for (const { payload } of cards) {
|
||||
for (const lang of payload.langs) {
|
||||
langs.add(lang);
|
||||
}
|
||||
}
|
||||
|
||||
await preloadHighlighter({
|
||||
themes: ["pierre-light", "pierre-dark"],
|
||||
langs: langs.size > 0 ? [...langs] : ["text"],
|
||||
});
|
||||
|
||||
syncDocumentTheme();
|
||||
|
||||
for (const { host, payload } of cards) {
|
||||
ensureShadowRoot(host);
|
||||
const diff = new FileDiff(createRenderOptions(payload));
|
||||
diff.hydrate({
|
||||
fileContainer: host,
|
||||
prerenderedHTML: payload.prerenderedHTML,
|
||||
...getHydrateProps(payload),
|
||||
});
|
||||
const controller = { payload, diff };
|
||||
controllers.push(controller);
|
||||
applyState(controller);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await hydrateViewer();
|
||||
document.documentElement.dataset.openclawDiffsReady = "true";
|
||||
} catch (error) {
|
||||
document.documentElement.dataset.openclawDiffsError = "true";
|
||||
console.error("Failed to hydrate diff viewer", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void main();
|
||||
});
|
||||
} else {
|
||||
void main();
|
||||
}
|
||||
Reference in New Issue
Block a user