import { FileDiff, preloadHighlighter } from "@pierre/diffs"; import type { FileContents, FileDiffMetadata, FileDiffOptions, SupportedLanguages, } from "@pierre/diffs"; import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js"; import { parseViewerPayloadJson } from "./viewer-payload.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 parseViewerPayloadJson(raw); } function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> { const cards: Array<{ host: HTMLElement; payload: DiffViewerPayload }> = []; for (const card of document.querySelectorAll(".oc-diff-card")) { const host = card.querySelector("[data-openclaw-diff-host]"); const payloadNode = card.querySelector("[data-openclaw-diff-payload]"); if (!host || !payloadNode) { continue; } try { cards.push({ host, payload: parsePayload(payloadNode) }); } catch (error) { console.warn("Skipping invalid diff payload", error); } } return cards; } function ensureShadowRoot(host: HTMLElement): void { if (host.shadowRoot) { return; } const template = host.querySelector( ":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; applyToolbarButtonStyles(button, params.active); button.addEventListener("click", (event) => { event.preventDefault(); params.onClick(); }); return button; } function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): void { button.style.color = viewerState.theme === "dark" ? "rgba(226, 232, 240, 0.74)" : "rgba(15, 23, 42, 0.52)"; button.dataset.active = String(active); } function splitIcon(): string { return ``; } function unifiedIcon(): string { return ``; } function wrapIcon(active: boolean): string { return ``; } function backgroundIcon(active: boolean): string { if (active) { return ``; } return ``; } function themeIcon(theme: DiffTheme): string { if (theme === "dark") { return ``; } return ``; } 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 { return { theme: payload.options.theme, themeType: viewerState.theme, diffStyle: viewerState.layout, diffIndicators: payload.options.diffIndicators, expandUnchanged: payload.options.expandUnchanged, overflow: viewerState.wrapEnabled ? "wrap" : "scroll", disableLineNumbers: payload.options.disableLineNumbers, 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 { const cards = getCards(); const langs = new Set(); const firstPayload = cards[0]?.payload; if (firstPayload) { viewerState.theme = firstPayload.options.themeType; viewerState.layout = firstPayload.options.diffStyle; viewerState.backgroundEnabled = firstPayload.options.backgroundEnabled; 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 { 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(); }