feat(diffs): changed-files summary nav for multi-file patch diffs (#100753)

* feat(diffs): add changed-files summary nav for multi-file patch diffs

* docs: regenerate docs map for diffs multi-file navigation section

* fix(diffs): respect reduced motion in summary navigation
This commit is contained in:
Peter Steinberger
2026-07-06 16:55:56 +01:00
committed by GitHub
parent 8547682dd6
commit dc600d4a77
7 changed files with 362 additions and 13 deletions

View File

@@ -9094,6 +9094,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Syntax highlighting
- H2: Output details contract
- H3: Collapsed unchanged sections
- H3: Multi-file navigation
- H2: Plugin defaults
- H3: Persistent viewer URL config
- H2: Security config

View File

@@ -221,6 +221,10 @@ All successful results include `changed`: identical before/after input returns `
The viewer shows rows like `N unmodified lines`. Expand controls only appear when the rendered diff has expandable context data (typical for before/after input). Many unified patches omit context bodies in their hunks, so the row can appear without an expand control -- expected, not a bug. `expandUnchanged` only applies when expandable context exists.
### Multi-file navigation
Patches that touch more than one file start with a changed-files summary card: total `+N` / `-N` counts, per-file counts, added/deleted/renamed badges, and anchor links that jump to each file. Rendered PNG/PDF files keep the per-file header counts but drop the interactive view toggles, since those are dead controls in a static file.
## Plugin defaults
Set plugin-wide defaults in `~/.openclaw/openclaw.json`:

View File

@@ -225,6 +225,8 @@ diff --git a/src/example.ts b/src/example.ts
## Notes
- Multi-file patches start with a changed-files summary card: totals, per-file `+N`/`-N` stats, change badges, and anchor links.
- Rendered PNG/PDF files keep the per-file header counts but omit the interactive view toggles.
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.

View File

@@ -40,6 +40,9 @@ describe("renderDiffDocument", () => {
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
expect(rendered.html).not.toContain('<nav class="oc-diff-card oc-diff-nav"');
expect(rendered.html).toContain("@media (prefers-reduced-motion: reduce)");
expect(rendered.html).toContain("scroll-behavior: auto;");
});
it("normalizes non-finite presentation numbers before rendering CSS", async () => {
@@ -173,14 +176,16 @@ describe("renderDiffDocument", () => {
expect(payload.langs).toEqual(["abap"]);
});
it("renders multi-file patch input", async () => {
it("renders multi-file patch input with a changed-files summary nav", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"@@ -1,2 +1,3 @@",
"-const a = 1;",
"+const a = 2;",
"+const extra = true;",
" const keep = 0;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
@@ -214,6 +219,101 @@ describe("renderDiffDocument", () => {
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
const html = rendered.html ?? "";
expect(html).toContain('<nav class="oc-diff-card oc-diff-nav" aria-label="Changed files">');
expect(html).toContain("2 changed files");
expect(html).toContain(
'<span class="oc-diff-nav-additions">+3</span><span class="oc-diff-nav-deletions">-2</span>',
);
expect(html).toContain(
'<span class="oc-diff-nav-additions">+2</span><span class="oc-diff-nav-deletions">-1</span>',
);
expect(html).toContain('href="#oc-diff-file-1"');
expect(html).toContain('id="oc-diff-file-1"');
expect(html).toContain('href="#oc-diff-file-2"');
expect(html).toContain('id="oc-diff-file-2"');
expect(rendered.imageHtml).toContain('<nav class="oc-diff-card oc-diff-nav"');
});
it("labels added, deleted, and renamed files in the summary nav and escapes names", async () => {
const patch = [
"diff --git a/new.ts b/new.ts",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.ts",
"@@ -0,0 +1 @@",
"+const created = true;",
"diff --git a/old.ts b/old.ts",
"deleted file mode 100644",
"--- a/old.ts",
"+++ /dev/null",
"@@ -1 +0,0 @@",
"-const removed = true;",
"diff --git a/before.ts b/after.ts",
"similarity index 90%",
"rename from before.ts",
"rename to after.ts",
"--- a/before.ts",
"+++ b/after.ts",
"@@ -1 +1 @@",
"-const v = 1;",
"+const v = 2;",
"diff --git a/a&b.ts b/a&b.ts",
"--- a/a&b.ts",
"+++ b/a&b.ts",
"@@ -1 +1 @@",
"-x",
"+y",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
"viewer",
);
const html = rendered.html ?? "";
expect(html).toContain("4 changed files");
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="added">added</span>');
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="deleted">deleted</span>');
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="renamed">renamed</span>');
expect(html).toContain("before.ts &rarr; after.ts");
expect(html).toContain("a&amp;b.ts");
});
it("omits the summary nav for single-file patches", async () => {
const patch = [
"diff --git a/solo.ts b/solo.ts",
"--- a/solo.ts",
"+++ b/solo.ts",
"@@ -1 +1 @@",
"-const solo = 1;",
"+const solo = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
"viewer",
);
expect(rendered.fileCount).toBe(1);
expect(rendered.html).not.toContain('<nav class="oc-diff-card oc-diff-nav"');
});
it("rejects patches that exceed file-count limits", async () => {

View File

@@ -202,8 +202,8 @@ function buildRenderVariants(params: { options: DiffRenderOptions; target: DiffR
};
}
function renderDiffCard(payload: DiffViewerPayload): string {
return `<section class="oc-diff-card">
function renderDiffCard(payload: DiffViewerPayload, anchorId?: string): string {
return `<section class="oc-diff-card"${anchorId ? ` id="${anchorId}"` : ""}>
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
</diffs-container>
@@ -211,6 +211,76 @@ function renderDiffCard(payload: DiffViewerPayload): string {
</section>`;
}
type FileDiffStats = {
additions: number;
deletions: number;
};
type FileNavEntry = {
anchorId: string;
fileDiff: FileDiffMetadata;
stats: FileDiffStats;
};
// Hunk.additionLines/deletionLines count only +/- lines (not context), so the
// sums match the built-in per-file header counts rendered by @pierre/diffs.
function computeFileDiffStats(fileDiff: FileDiffMetadata): FileDiffStats {
let additions = 0;
let deletions = 0;
for (const hunk of fileDiff.hunks) {
additions += hunk.additionLines;
deletions += hunk.deletionLines;
}
return { additions, deletions };
}
function renderNavChangeBadge(changeType: FileDiffMetadata["type"]): string {
const label =
changeType === "new"
? "added"
: changeType === "deleted"
? "deleted"
: changeType === "rename-pure" || changeType === "rename-changed"
? "renamed"
: undefined;
return label ? `<span class="oc-diff-nav-badge" data-change="${label}">${label}</span>` : "";
}
function renderNavStats(stats: FileDiffStats): string {
return `<span class="oc-diff-nav-stats"><span class="oc-diff-nav-additions">+${stats.additions}</span><span class="oc-diff-nav-deletions">-${stats.deletions}</span></span>`;
}
function renderNavEntryName(fileDiff: FileDiffMetadata): string {
const renamed = fileDiff.prevName && fileDiff.prevName !== fileDiff.name;
return renamed
? `${escapeHtml(fileDiff.prevName ?? "")} &rarr; ${escapeHtml(fileDiff.name)}`
: escapeHtml(fileDiff.name);
}
// Multi-file patches render as stacked cards; this summary card gives per-file
// stats plus anchor links so long diffs stay navigable without extra JS.
function renderFileSummaryNav(entries: ReadonlyArray<FileNavEntry>): string {
const totals = entries.reduce<FileDiffStats>(
(sum, entry) => ({
additions: sum.additions + entry.stats.additions,
deletions: sum.deletions + entry.stats.deletions,
}),
{ additions: 0, deletions: 0 },
);
const items = entries
.map(
(entry) =>
`<li><a href="#${entry.anchorId}"><code>${renderNavEntryName(entry.fileDiff)}</code></a>${renderNavChangeBadge(entry.fileDiff.type)}${renderNavStats(entry.stats)}</li>`,
)
.join("\n ");
return `<nav class="oc-diff-card oc-diff-nav" aria-label="Changed files">
<p class="oc-diff-nav-summary">${entries.length} changed files${renderNavStats(totals)}</p>
<ol class="oc-diff-nav-list">
${items}
</ol>
</nav>`;
}
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
@@ -255,6 +325,13 @@ function buildHtmlDocument(params: {
html {
background: #05070b;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
@@ -309,7 +386,91 @@ ${imageTypographyCss}
display: block;
}
.oc-frame[data-render-mode="image"] .oc-diff-card {
.oc-diff-nav {
padding: 14px 18px;
font-size: 13px;
line-height: 1.5;
}
.oc-diff-nav-summary {
display: flex;
align-items: center;
margin: 0 0 10px;
font-weight: 600;
}
.oc-diff-nav-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.oc-diff-nav-list li {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.oc-diff-nav-list code {
overflow-wrap: anywhere;
}
.oc-diff-nav-list a {
color: inherit;
text-decoration: none;
min-width: 0;
}
.oc-diff-nav-list a:hover {
text-decoration: underline;
}
.oc-diff-nav-stats {
display: inline-flex;
gap: 8px;
margin-inline-start: auto;
padding-inline-start: 12px;
font-variant-numeric: tabular-nums;
font-weight: 600;
white-space: nowrap;
}
.oc-diff-nav-summary .oc-diff-nav-stats {
margin-inline-start: 10px;
padding-inline-start: 0;
}
.oc-diff-nav-additions {
color: #4ade80;
}
.oc-diff-nav-deletions {
color: #f87171;
}
.oc-diff-nav-badge {
flex: 0 0 auto;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.32);
font-size: 11px;
opacity: 0.85;
}
body[data-theme="light"] .oc-diff-nav-additions {
color: #15803d;
}
body[data-theme="light"] .oc-diff-nav-deletions {
color: #b91c1c;
}
/* Nav summary cards are short; the diff-card floor would pad them with
empty space in static PNG/PDF captures. */
.oc-frame[data-render-mode="image"] .oc-diff-card:not(.oc-diff-nav) {
min-height: 240px;
}
@@ -348,24 +509,33 @@ function payloadUsesLanguagePack(payload: DiffViewerPayload | undefined): boolea
function buildRenderedSection(params: {
viewerPayload?: DiffViewerPayload;
imagePayload?: DiffViewerPayload;
anchorId?: string;
}): RenderedSection {
return {
...(params.viewerPayload ? { viewer: renderDiffCard(params.viewerPayload) } : {}),
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload) } : {}),
...(params.viewerPayload
? { viewer: renderDiffCard(params.viewerPayload, params.anchorId) }
: {}),
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload, params.anchorId) } : {}),
usesLanguagePack:
payloadUsesLanguagePack(params.viewerPayload) || payloadUsesLanguagePack(params.imagePayload),
};
}
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
function buildRenderedBodies(
sections: ReadonlyArray<RenderedSection>,
leadingHtml?: string,
): {
viewerBodyHtml?: string;
imageBodyHtml?: string;
} {
const lead = leadingHtml ? [leadingHtml] : [];
const viewerSections = sections.flatMap((section) => (section.viewer ? [section.viewer] : []));
const imageSections = sections.flatMap((section) => (section.image ? [section.image] : []));
return {
...(viewerSections.length > 0 ? { viewerBodyHtml: viewerSections.join("\n") } : {}),
...(imageSections.length > 0 ? { imageBodyHtml: imageSections.join("\n") } : {}),
...(viewerSections.length > 0
? { viewerBodyHtml: [...lead, ...viewerSections].join("\n") }
: {}),
...(imageSections.length > 0 ? { imageBodyHtml: [...lead, ...imageSections].join("\n") } : {}),
};
}
@@ -471,8 +641,13 @@ async function renderPatchDiff(
if (!preloadOptions) {
throw new Error(`Unsupported diff render target: ${target}`);
}
const navEntries: FileNavEntry[] = files.map((fileDiff, index) => ({
anchorId: `oc-diff-file-${index + 1}`,
fileDiff,
stats: computeFileDiffStats(fileDiff),
}));
const sections = await Promise.all(
files.map(async (fileDiff) => {
files.map(async (fileDiff, index) => {
const preloadResult = await preloadFileDiffWithFallback({
fileDiff,
options: preloadOptions,
@@ -496,12 +671,15 @@ async function renderPatchDiff(
return buildRenderedSection({
...(viewerPayload ? { viewerPayload } : {}),
...(imagePayload ? { imagePayload } : {}),
anchorId: navEntries[index]?.anchorId,
});
}),
);
// Single-file patches skip the summary card; one file needs no navigation.
const navHtml = files.length > 1 ? renderFileSummaryNav(navEntries) : undefined;
return {
...buildRenderedBodies(sections),
...buildRenderedBodies(sections, navHtml),
fileCount: files.length,
usesLanguagePack: sections.some((section) => section.usesLanguagePack === true),
};

View File

@@ -400,6 +400,63 @@ describe("toolbar button toggles", () => {
});
});
describe("header metadata", () => {
beforeEach(() => {
document.body.innerHTML = "";
delete document.body.dataset.theme;
vi.clearAllMocks();
});
type HeaderMetadataCallback = () => HTMLElement | null;
async function hydrateAndGetHeaderCallback(): Promise<HeaderMetadataCallback> {
const { hydrateViewer } = await import("./viewer-client.js");
await hydrateViewer();
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
return opts.renderHeaderMetadata as HeaderMetadataCallback;
}
it("renders the toolbar in viewer render mode", async () => {
document.body.insertAdjacentHTML(
"beforeend",
'<main class="oc-frame" data-render-mode="viewer"></main>',
);
renderCard();
const renderHeaderMetadata = await hydrateAndGetHeaderCallback();
const header = renderHeaderMetadata();
expect(header).not.toBeNull();
expect(header?.querySelectorAll("button")).toHaveLength(4);
});
it("drops the interactive toolbar in image render mode", async () => {
document.body.insertAdjacentHTML(
"beforeend",
'<main class="oc-frame" data-render-mode="image"></main>',
);
renderCard();
const renderHeaderMetadata = await hydrateAndGetHeaderCallback();
expect(renderHeaderMetadata()).toBeNull();
});
it("skips summary nav cards during hydration", async () => {
document.body.insertAdjacentHTML(
"beforeend",
'<nav class="oc-diff-card oc-diff-nav" aria-label="Changed files"><ol></ol></nav>',
);
renderCard();
const { controllers, hydrateViewer } = await import("./viewer-client.js");
controllers.splice(0);
await hydrateViewer();
expect(controllers).toHaveLength(1);
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(1);
});
});
describe("ensureShadowRoot", () => {
beforeEach(() => {
document.body.innerHTML = "";

View File

@@ -214,6 +214,10 @@ function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): v
icon.style.pointerEvents = "none";
}
function isImageRenderMode(): boolean {
return document.querySelector("main.oc-frame")?.getAttribute("data-render-mode") === "image";
}
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
@@ -283,7 +287,10 @@ function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefi
disableLineNumbers: payload.options.disableLineNumbers,
disableBackground: !viewerState.backgroundEnabled,
unsafeCSS: payload.options.unsafeCSS,
renderHeaderMetadata: () => createToolbar(),
// Image/PDF exports are static; the interactive toggle toolbar would
// render as dead UI in the captured file. Returning null keeps the
// library's built-in +N/-N header counts without the buttons.
renderHeaderMetadata: () => (isImageRenderMode() ? null : createToolbar()),
};
}