mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 01:03:53 +00:00
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:
committed by
GitHub
parent
8547682dd6
commit
dc600d4a77
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 → after.ts");
|
||||
expect(html).toContain("a&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 () => {
|
||||
|
||||
@@ -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 ?? "")} → ${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),
|
||||
};
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user