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
+188 -10
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),
};