Skip to content

Commit dc600d4

Browse files
authored
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
1 parent 8547682 commit dc600d4

7 files changed

Lines changed: 362 additions & 13 deletions

File tree

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9094,6 +9094,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
90949094
- H2: Syntax highlighting
90959095
- H2: Output details contract
90969096
- H3: Collapsed unchanged sections
9097+
- H3: Multi-file navigation
90979098
- H2: Plugin defaults
90989099
- H3: Persistent viewer URL config
90999100
- H2: Security config

docs/tools/diffs.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ All successful results include `changed`: identical before/after input returns `
221221

222222
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.
223223

224+
### Multi-file navigation
225+
226+
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.
227+
224228
## Plugin defaults
225229

226230
Set plugin-wide defaults in `~/.openclaw/openclaw.json`:

extensions/diffs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ diff --git a/src/example.ts b/src/example.ts
225225

226226
## Notes
227227

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

extensions/diffs/src/render.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ describe("renderDiffDocument", () => {
4040
expect(rendered.html).toContain("--diffs-line-height: 24px;");
4141
expect(rendered.html).toContain("--diffs-font-size: 15px;");
4242
expect(rendered.html).not.toContain("fonts.googleapis.com");
43+
expect(rendered.html).not.toContain('<nav class="oc-diff-card oc-diff-nav"');
44+
expect(rendered.html).toContain("@media (prefers-reduced-motion: reduce)");
45+
expect(rendered.html).toContain("scroll-behavior: auto;");
4346
});
4447

4548
it("normalizes non-finite presentation numbers before rendering CSS", async () => {
@@ -173,14 +176,16 @@ describe("renderDiffDocument", () => {
173176
expect(payload.langs).toEqual(["abap"]);
174177
});
175178

176-
it("renders multi-file patch input", async () => {
179+
it("renders multi-file patch input with a changed-files summary nav", async () => {
177180
const patch = [
178181
"diff --git a/a.ts b/a.ts",
179182
"--- a/a.ts",
180183
"+++ b/a.ts",
181-
"@@ -1 +1 @@",
184+
"@@ -1,2 +1,3 @@",
182185
"-const a = 1;",
183186
"+const a = 2;",
187+
"+const extra = true;",
188+
" const keep = 0;",
184189
"diff --git a/b.ts b/b.ts",
185190
"--- a/b.ts",
186191
"+++ b/b.ts",
@@ -214,6 +219,101 @@ describe("renderDiffDocument", () => {
214219
expect(rendered.fileCount).toBe(2);
215220
expect(rendered.html).toContain("Workspace patch");
216221
expect(rendered.imageHtml).toContain("max-width: 1180px;");
222+
223+
const html = rendered.html ?? "";
224+
expect(html).toContain('<nav class="oc-diff-card oc-diff-nav" aria-label="Changed files">');
225+
expect(html).toContain("2 changed files");
226+
expect(html).toContain(
227+
'<span class="oc-diff-nav-additions">+3</span><span class="oc-diff-nav-deletions">-2</span>',
228+
);
229+
expect(html).toContain(
230+
'<span class="oc-diff-nav-additions">+2</span><span class="oc-diff-nav-deletions">-1</span>',
231+
);
232+
expect(html).toContain('href="#oc-diff-file-1"');
233+
expect(html).toContain('id="oc-diff-file-1"');
234+
expect(html).toContain('href="#oc-diff-file-2"');
235+
expect(html).toContain('id="oc-diff-file-2"');
236+
expect(rendered.imageHtml).toContain('<nav class="oc-diff-card oc-diff-nav"');
237+
});
238+
239+
it("labels added, deleted, and renamed files in the summary nav and escapes names", async () => {
240+
const patch = [
241+
"diff --git a/new.ts b/new.ts",
242+
"new file mode 100644",
243+
"--- /dev/null",
244+
"+++ b/new.ts",
245+
"@@ -0,0 +1 @@",
246+
"+const created = true;",
247+
"diff --git a/old.ts b/old.ts",
248+
"deleted file mode 100644",
249+
"--- a/old.ts",
250+
"+++ /dev/null",
251+
"@@ -1 +0,0 @@",
252+
"-const removed = true;",
253+
"diff --git a/before.ts b/after.ts",
254+
"similarity index 90%",
255+
"rename from before.ts",
256+
"rename to after.ts",
257+
"--- a/before.ts",
258+
"+++ b/after.ts",
259+
"@@ -1 +1 @@",
260+
"-const v = 1;",
261+
"+const v = 2;",
262+
"diff --git a/a&b.ts b/a&b.ts",
263+
"--- a/a&b.ts",
264+
"+++ b/a&b.ts",
265+
"@@ -1 +1 @@",
266+
"-x",
267+
"+y",
268+
].join("\n");
269+
270+
const rendered = await renderDiffDocument(
271+
{
272+
kind: "patch",
273+
patch,
274+
},
275+
{
276+
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
277+
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
278+
expandUnchanged: false,
279+
},
280+
"viewer",
281+
);
282+
283+
const html = rendered.html ?? "";
284+
expect(html).toContain("4 changed files");
285+
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="added">added</span>');
286+
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="deleted">deleted</span>');
287+
expect(html).toContain('<span class="oc-diff-nav-badge" data-change="renamed">renamed</span>');
288+
expect(html).toContain("before.ts &rarr; after.ts");
289+
expect(html).toContain("a&amp;b.ts");
290+
});
291+
292+
it("omits the summary nav for single-file patches", async () => {
293+
const patch = [
294+
"diff --git a/solo.ts b/solo.ts",
295+
"--- a/solo.ts",
296+
"+++ b/solo.ts",
297+
"@@ -1 +1 @@",
298+
"-const solo = 1;",
299+
"+const solo = 2;",
300+
].join("\n");
301+
302+
const rendered = await renderDiffDocument(
303+
{
304+
kind: "patch",
305+
patch,
306+
},
307+
{
308+
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
309+
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
310+
expandUnchanged: false,
311+
},
312+
"viewer",
313+
);
314+
315+
expect(rendered.fileCount).toBe(1);
316+
expect(rendered.html).not.toContain('<nav class="oc-diff-card oc-diff-nav"');
217317
});
218318

219319
it("rejects patches that exceed file-count limits", async () => {

0 commit comments

Comments
 (0)