feat(diff): copy to clipboard markdown (#187) (#195)
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 36s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (push) Successful in 1m12s
CI / E2E (Standalone single-file) (push) Successful in 2m52s
CI / E2E (Web) (push) Successful in 4m36s

This commit is contained in:
forgejo_admin 2026-05-22 23:54:59 +04:00
parent e35f8aad2d
commit 4efdd770dd
15 changed files with 2903 additions and 114 deletions

View file

@ -1427,17 +1427,17 @@
"diffGroupRemoved": {
"en": "{count} removed",
"es": "{count} eliminados",
"ar": "{count} تمت إزالته"
"ar": "تمت إزالة {count}"
},
"diffGroupModified": {
"en": "{count} modified",
"es": "{count} modificados",
"ar": "{count} تم تعديله"
"ar": "تم تعديل {count}"
},
"diffGroupKept": {
"en": "{count} kept",
"es": "{count} conservados",
"ar": "{count} تم الاحتفاظ به"
"ar": "تم الاحتفاظ بـ {count}"
},
"diffGroupSeparator": {
"en": ", ",
@ -1477,7 +1477,7 @@
"diffGroupAdded": {
"en": "{count} added",
"es": "{count} añadidos",
"ar": "{count} تمت إضافته"
"ar": "تمت إضافة {count}"
},
"diffEmptyValue": {
"en": "—",
@ -1494,6 +1494,36 @@
"es": "Cargando lector de metadatos…",
"ar": "جارٍ تحميل قارئ البيانات الوصفية…"
},
"diffCopyButton": {
"en": "Copy",
"es": "Copiar",
"ar": "نسخ"
},
"diffClipboardHeader": {
"en": "Metadata diff",
"es": "Diferencia de metadatos",
"ar": "الفرق في البيانات الوصفية"
},
"diffClipboardRemoved": {
"en": "removed",
"es": "eliminado",
"ar": "تمت إزالته"
},
"diffClipboardNone": {
"en": "none",
"es": "ninguno",
"ar": "لا شيء"
},
"diffClipboardKept": {
"en": "kept",
"es": "conservado",
"ar": "محفوظ"
},
"copyToast": {
"en": "Copied to clipboard",
"es": "Copiado al portapapeles",
"ar": "تم النسخ إلى الحافظة"
},
"zipExpansion.statusCleaned": {
"en": "Cleaned",
"es": "Limpiado",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,528 @@
# Copy Diff to Clipboard — Design Spec
**Issue:** #187 — copy to clipboard diff (before and after)
**Date:** 2026-05-22
**Status:** Ready for implementation
---
## Problem
The two-pane metadata diff (`MetadataDiffTable`) shows what was removed, modified, added, or
kept between the original file and the stripped output. Users currently have no way to copy
this diff out of the app — they'd have to screenshot it or manually retype each row to
share it in a bug report, chat thread, forensic note, or compliance log.
The diff feature is the audit trail of what the app actually did to a file. Making that
audit trail trivially copy-pasteable is essentially free engineering — the data is already
in `MetadataDocument` form, and the rest of the app already has a clipboard pattern in
`ErrorExpansion` plus a toast pipeline (`onCopyToast`) wired through `FileTable`
`FileRow`.
---
## Decisions
| Question | Decision |
|---|---|
| Where does the copy affordance live? | A single `Copy` button at the top-right of the diff expansion, beside the BEFORE/AFTER pane labels |
| What gets copied? | The full diff for that file (no per-source partial copy) |
| Clipboard format | Markdown — bullet-list per source group with arrow notation; renders cleanly on Forgejo/GitHub, stays legible in plain-text destinations |
| Include "kept" entries? | Yes — match the in-app diff faithfully so the audit trail is complete |
| Inner ZIP leaf diffs | Yes — copy button appears on every cleaned-leaf diff inside a ZIP archive (uses leaf's full archive-relative path in the header) |
| Visual distinction for removed | Bold field name on `removed` rows |
| Visual distinction for modified | None beyond the `before → after` notation |
| Filename header | Yes — single line `**Metadata diff** — \`filename\`` |
| Total summary line | Yes — second line: `N removed · N modified · N added · N kept` |
| Per-source summary line | Yes — appended to each `###` source heading, matching the in-app group summary |
| Skeleton / loading state | No copy button — nothing to copy yet |
| Empty diff state ("already clean") | No copy button — nothing meaningful |
| Toast on copy | Reuse existing `onCopyToast` pipeline (currently fires for `ErrorExpansion` copies) |
| i18n scope for new strings | en / es / ar (matches the newer-keys pattern already in `strings.json` for diff/zip keys) |
| Lift hardcoded "Copied to clipboard" toast string | Yes — move to i18n while we're touching the toast pipeline |
---
## Architecture
```
src/web/
utils/
format_diff_clipboard.ts ← NEW: pure markdown formatter (MetadataDocument → string)
components/
icons/
ClipboardIcon.tsx ← NEW: inline SVG glyph
file-list/
MetadataDiffTable.tsx ← add onCopy prop, render Copy button in pane-label row
MetadataDiffExpansion.tsx ← add onCopyToast prop, build the copy handler
FileRow.tsx ← pipe onCopyToast to MetadataDiffExpansion + ZipExpansion
ZipExpansion.tsx ← thread onCopyToast through recursion to leaf MetadataDiffTable
file-list/
FileTable.tsx ← lift hardcoded "Copied to clipboard" to i18n
styles/
file_table.css ← .file-table__diff-copy-btn rule
tests/web/
utils/
format_diff_clipboard.test.ts ← NEW: pure formatter unit tests
tests/e2e/web/
metadata-diff.spec.ts ← extend (or add new spec) for click-Copy → toast + clipboard-read assert
.resources/strings.json ← add 6 keys × 3 locales
```
Nothing in `src/domain/`, `src/application/`, or `src/infrastructure/` changes. This is a
pure presentation-layer feature: it reads from the existing `MetadataDocument` shape and
writes to the existing `onCopyToast` toast.
---
## Clipboard format
The clipboard payload is markdown. Each diff produces one self-contained document with a
header, a total-summary line, then one `###` section per source group, each section
containing one bullet per metadata entry.
```
**Metadata diff** — `IMG_1234.jpg`
6 removed · 1 modified · 1 added · 2 kept
### EXIF · 2 removed · 1 added · 1 kept
- **DateTimeOriginal**: `2024:03:14 10:32:00` → (removed)
- **GPSLatitude**: `37.774929` → (removed)
- Orientation: (none) → `1`
- ColorSpace: `sRGB` (kept)
### XMP · 4 removed · 1 modified · 1 kept
- **XMP:CreatorTool**: `Lightroom 12.0` → (removed)
- **XMP:Creator**: `Jane Doe` → (removed)
- **XMP:CreateDate**: `2024:03:14` → (removed)
- **XMP:Subject**: `vacation, beach` → (removed)
- XMP:Rating: `4``5`
- XMP:Format: `image/jpeg` (kept)
```
### Formatting rules
1. **Header line**`**Metadata diff** — \`{filename}\`` where `{filename}` is the
display path. For top-level file rows this is `file.name`. For ZIP inner leaves this is
the leaf's archive-relative path (`entry.path`, which already encodes nested
sub-folders like `inner/photo.jpg`).
2. **Total summary line** — comma-separated (`·` separator from existing `diffGroupSeparator`)
counts in order `removed / modified / added / kept`. **Skip statuses with count 0** to
mirror the existing in-app `makePaneGroupSummary` behavior.
3. **Source sections** — one `###` heading per source group, in the order sources first
appear in `document.before` then `document.after` (matches existing
`groupRowsBySource` ordering). Per-source summary suffix uses the same skip-zeros rule.
4. **Bullet rows**, per status:
- `removed``- **{name}**: \`{beforeValue}\` → (removed)` *(field name bold)*
- `added``- {name}: (none) → \`{afterValue}\``
- `modified``- {name}: \`{beforeValue}\` → \`{afterValue}\``
- `kept``- {name}: \`{value}\` (kept)`
5. **Value escaping** — values are wrapped in single backticks by default. If a value
contains a literal backtick (rare in real metadata; never seen in our forensic
corpus), the wrapper switches to double backticks per the standard CommonMark inline
code rule (e.g. `` `` `foo` `` ``). If a value contains a newline character (XMP can
carry these), the literal `\n` / `\r` are replaced with the visible escape sequences
`\\n` / `\\r` so the inline code span stays on one line and the markdown remains
valid. Trailing whitespace is preserved as-is — round-trip fidelity matters in audit
contexts.
6. **Empty values in `before` / `after`** — the `(none)` placeholder is used for the
missing side in `added` / `removed`. This is i18n'd (`diffClipboardNone`) so RTL
languages render naturally.
7. **Trailing newline** — final newline after the last bullet, so a paste into a chat
buffer doesn't leave the cursor mid-line.
### Pure-function contract
```ts
export function formatDiffForClipboard({
document,
filename,
t,
}: {
document: MetadataDocument;
filename: string;
t: (key: string) => string;
}): string;
```
- Reuses `computeDiffRows()` and `groupRowsBySource()` logic from `MetadataDiffTable`
to avoid drift, those helpers move from `MetadataDiffTable.tsx` to a shared module
(`src/web/utils/diff_rows.ts`) and both files import them.
- No DOM, no React. Pure `MetadataDocument → string`. Trivially unit-testable.
---
## UI changes
### Copy button placement
`MetadataDiffTable` already renders a header row with `BEFORE` / `AFTER` pane labels:
```tsx
<div className="file-table__diff-pane-header">
<span ...>{t("diffPaneBefore")}</span>
<span ...>{t("diffPaneAfter")}</span>
</div>
```
Add a right-aligned Copy button to this row:
```tsx
<div className="file-table__diff-pane-header">
<span ...>{t("diffPaneBefore")}</span>
<span ...>{t("diffPaneAfter")}</span>
{onCopy !== undefined && (
<button
type="button"
className="file-table__diff-copy-btn"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
aria-label={t("diffCopyButton")}
>
<ClipboardIcon />
<span>{t("diffCopyButton")}</span>
</button>
)}
</div>
```
- `onCopy` is optional — `MetadataDiffTable` is also rendered in places where wiring it
doesn't make sense (e.g. test harness mounts), so the button is gated behind the prop.
- `e.stopPropagation()` keeps the click from bubbling up to the row's expand/collapse
handler.
- The button is `position: absolute; top: 0; right: 0;` (or flex-end on the header row) so
it doesn't reshape the pane label layout on narrow mobile viewports. CSS goes in
`file_table.css`.
### Mobile layout
On the Android APK / 360px viewport, the button collapses to icon-only (the existing
mobile media-query CSS toggles `.file-table__diff-copy-btn span { display: none }` below
`480px`). The icon retains the `aria-label` for screen-reader users.
### Skeleton / empty states
`MetadataDiffTable` is only rendered when `diffDocument.before.length > 0 ||
diffDocument.after.length > 0` (see `MetadataDiffExpansion`). The button shows in every
case where the table renders. The skeleton and "(already clean)" branches don't render
`MetadataDiffTable` at all, so they naturally get no button — no extra gating needed.
### Coordination with PR #189 (`fix/android-downloads`)
PR #189 adds a per-row **Share** icon in the RESULT column and a batch **"Share zip"**
button in `FileTable`. The diff Copy button is in a different DOM subtree (inside the
expansion panel, only visible when the diff is open) and uses a distinct CSS namespace
(`.file-table__diff-copy-btn` vs `.file-table__share` / `.file-table__share-zip-btn`).
Expected merge conflicts are limited to imports and prop-wiring in `FileRow.tsx` /
`FileTable.tsx` — textual, not semantic.
Branching strategy: branch off `master`. If #189 lands first (likely — Android downloads
is a release-blocker), rebase this branch onto it. If this branch lands first, #189
rebases.
---
## i18n changes
### New keys (en / es / ar)
| Key | en | es | ar |
|---|---|---|---|
| `diffCopyButton` | `Copy` | `Copiar` | `نسخ` |
| `diffClipboardHeader` | `Metadata diff` | `Diferencia de metadatos` | `الفرق في البيانات الوصفية` |
| `diffClipboardRemoved` | `(removed)` | `(eliminado)` | `(تمت إزالته)` |
| `diffClipboardNone` | `(none)` | `(ninguno)` | `(لا شيء)` |
| `diffClipboardKept` | `(kept)` | `(conservado)` | `(محفوظ)` |
| `copyToast` | `Copied to clipboard` | `Copiado al portapapeles` | `تم النسخ إلى الحافظة` |
Adding to only en / es / ar matches the existing pattern for newer keys in
`strings.json` (see `diffGroupRemoved`, `diffPaneBefore`, `zipExpansion.*`, etc.). The
locale-fallback chain in `i18nLookup()` handles other languages by falling back to
English.
### Reused keys (no change)
- `diffGroupRemoved` / `diffGroupModified` / `diffGroupAdded` / `diffGroupKept`
templates like `{count} removed`. Used for both the total summary line and the
per-source summary suffix.
- `diffGroupSeparator` — the `·` (en/es) / `، ` (ar) separator between summary chunks.
### Lift hardcoded toast
`FileTable.tsx` currently hardcodes the toast text:
```ts
const handleCopyToast = useCallback(() => {
showToast("Copied to clipboard");
}, []);
```
Replace with `t("copyToast")`. The `useCallback` dep array grows from `[]` to `[t]`.
(Note: `t` is stable across renders in `useI18n`, so the practical re-render impact is
nil — but the dep array still needs to be accurate.)
---
## Code changes (detailed)
### New: `src/web/utils/format_diff_clipboard.ts`
```ts
import type { MetadataDocument } from "../../domain";
import { computeDiffRows, groupRowsBySource } from "./diff_rows";
export function formatDiffForClipboard({
document,
filename,
t,
}: {
document: MetadataDocument;
filename: string;
t: (key: string) => string;
}): string {
const rows = computeDiffRows(document);
const grouped = groupRowsBySource(rows);
const totals = countByStatus(rows);
const lines: string[] = [];
lines.push(`**${t("diffClipboardHeader")}** — \`${filename}\``);
lines.push(renderSummary(totals, t));
lines.push("");
for (const group of grouped) {
const groupTotals = countByStatus(group.rows);
lines.push(`### ${group.source} · ${renderSummary(groupTotals, t)}`);
for (const row of group.rows) {
lines.push(renderBullet(row, t));
}
lines.push("");
}
return lines.join("\n").trimEnd() + "\n";
}
```
(`countByStatus`, `renderSummary`, `renderBullet`, value-escape helper, etc. are
file-local helpers.)
### New: `src/web/utils/diff_rows.ts`
Move `computeDiffRows`, `groupRowsBySource`, `makeKey`, `DiffRow`, `SourceRowGroup`,
`DiffRowStatus` out of `MetadataDiffTable.tsx` into this shared module. `MetadataDiffTable`
imports them; the new formatter imports them. Avoids code-duplication drift.
### New: `src/web/components/icons/ClipboardIcon.tsx`
Inline SVG glyph for the Copy button. Same style as `ChevronIcon`, `GearIcon`,
`ShareIcon``currentColor` stroke, 14×14 viewport, named export.
### Modified: `src/web/components/file-list/MetadataDiffTable.tsx`
- Drop `computeDiffRows` / `groupRowsBySource` (moved to `utils/diff_rows.ts`).
- Add optional `onCopy?: () => void` prop.
- Render the Copy button in the existing pane-label header row.
### Modified: `src/web/components/file-list/MetadataDiffExpansion.tsx`
- Add `onCopyToast` prop.
- Construct `handleCopy`:
```ts
const handleCopy = useCallback((): void => {
const text = formatDiffForClipboard({ document: diffDocument!, filename, t });
navigator.clipboard.writeText(text).then(onCopyToast, () => { /* silent */ });
}, [diffDocument, filename, t, onCopyToast]);
```
- Pass `handleCopy` to `MetadataDiffTable` via `onCopy`.
- Add `filename` prop (so the formatter has it) — defaults to `""` if a caller doesn't
provide one (defensive; in practice every caller does).
### Modified: `src/web/components/file-list/FileRow.tsx`
- Pipe `onCopyToast` to both `MetadataDiffExpansion` (already gets `filename={file.name}`)
and `ZipExpansion`.
### Modified: `src/web/components/file-list/ZipExpansion.tsx`
- Add `onCopyToast` prop to `ZipExpansion` itself; thread to recursive
`<ZipExpansion>` calls.
- Pass `onCopyToast` and the leaf's `fullPath` (or `entry.path` — discussed in
Implementation Q&A below) into `renderLeafBody`, which then passes them to
`MetadataDiffTable` as `onCopy` (constructed locally inside `renderLeafBody`).
### Modified: `src/web/components/file-list/FileTable.tsx`
- Lift the hardcoded `"Copied to clipboard"` string to `t("copyToast")`.
### Modified: `.resources/strings.json`
- Add the 6 new keys (en / es / ar each) listed above.
### Modified: `src/web/styles/file_table.css`
- Add `.file-table__diff-copy-btn` rules:
- Reset button defaults (background, border, padding).
- Inline-flex, `gap: 4px`, color = `var(--ec-color-text-secondary)`.
- Hover: `color: var(--ec-color-text-primary)`.
- Position in the pane header (`margin-left: auto` if the header is flex; or absolute
top-right if not).
- Mobile (`@media (max-width: 480px)`): hide the text span, keep the icon.
---
## Tests
### New: `tests/web/utils/format_diff_clipboard.test.ts`
Pure formatter tests. The `t` function is mocked to identity (returns key) so test
assertions don't depend on i18n strings — they just verify the structural shape of the
output.
Cases:
1. **Empty document** — both `before` and `after` empty. Output is just the header line
+ an empty summary line (or assert this branch isn't reached in practice; gated by
`MetadataDiffExpansion`).
2. **Removed-only** — one source, three removed entries.
3. **Added-only** — one source, two added entries.
4. **Modified-only** — one source, two modified entries.
5. **Kept-only** — one source, two kept entries.
6. **Mixed statuses, single source**.
7. **Multiple sources** — EXIF + XMP + IPTC with mixed statuses; verify each gets its
own `###` heading and the source ordering matches discovery order.
8. **Total summary** — verify the totals on line 2 match the row counts.
9. **Per-source summary** — verify each `###` heading suffix matches the per-source
counts; zero counts are skipped.
10. **Bold on removed** — verify `**FieldName**:` format for removed; non-removed rows
don't bold the name.
11. **Value with backtick** — verify the wrapper switches to double backticks.
12. **Value with newline** — verify literal `\n` / `\r` are replaced with the visible
escape sequences `\\n` / `\\r`.
13. **Empty filename** — verify the header still renders without crashing.
14. **Trailing newline** — final line ends in `\n`, but no double-newline at EOF.
### Modified: `tests/web/components/file_row.test.tsx`
The existing test exercises `onCopyToast` for the error path. Extend (or add a sibling
test) for: when the file has a `diffDocument`, expand the row, click the Copy button,
assert `onCopyToast` was called.
### Modified: `tests/web/components/zip_expansion.test.tsx` (if exists, else new)
Verify the Copy button appears on a cleaned-leaf diff inside ZipExpansion and clicking
it fires `onCopyToast`. Mock `navigator.clipboard.writeText` to resolve.
### New: e2e — extend `tests/e2e/web/metadata-diff.spec.ts` (or add a new spec)
End-to-end flow:
1. Drop a JPEG with EXIF metadata.
2. Wait for the diff to land.
3. Expand the row.
4. Click `Copy`.
5. Assert the toast appears with the i18n'd copy text.
6. Read `navigator.clipboard.readText()` (Playwright supports this with the
`clipboard-read` permission grant) and assert the markdown payload matches the
expected shape.
Clipboard permission is granted via `context.grantPermissions(['clipboard-read',
'clipboard-write'])` in the test setup.
---
## Implementation Q&A
### Q: For ZIP leaf diffs, what filename goes in the markdown header?
The leaf's archive-relative path. `ZipExpansion` already constructs `fullPath =
pathPrefix + entry.path` with a `\0` separator for nested archives. For the clipboard
header we want a human-readable path, so:
- Use `entry.path` for shallow archives (no nested zips). Renders as e.g. `inner/photo.jpg`.
- For nested zips, transform the `\0`-joined `fullPath` to `>`-joined: `outer.zip >
inner/photo.jpg`. This is purely for human readability in the clipboard header; the
internal `fullPath` keying is unchanged.
A small helper:
```ts
function leafDisplayPath(fullPath: string): string {
return fullPath.split("\0").filter((s) => s.length > 0).join(" > ");
}
```
### Q: Does `navigator.clipboard.writeText` work on the standalone HTML (file:// origin) and inside the Android Capacitor WebView?
- **Capacitor WebView** — serves the app over `https://localhost/`, which is a secure
context. Clipboard API works.
- **Desktop browsers** — modern Chromium / Firefox / Safari allow
`navigator.clipboard.writeText` from a user gesture. The Copy button click counts as a
user gesture.
- **Standalone HTML opened from `file://`** — Chromium and Safari treat `file://` as a
potentially-trustworthy origin and allow `writeText` from a user gesture. Firefox is
more restrictive; it may prompt or silently fail. The existing `ErrorExpansion` uses
the same API in the same context and works, so this is a precedent, not a new risk.
The implementation silently swallows clipboard failures (matches `ErrorExpansion`):
```ts
navigator.clipboard.writeText(text).then(onCopyToast, () => { /* no-op */ });
```
Future improvement (out of scope for #187): a fallback toast on failure ("Clipboard
unavailable") and an `execCommand("copy")` fallback. Tracked separately if it ever
matters.
### Q: Why move `computeDiffRows` to a shared module instead of calling it from `MetadataDiffTable`?
Two reasons:
1. The formatter needs to run *without* mounting the React component. Importing from a
component file forces React import overhead on the formatter and any test that uses
it.
2. Keeping the diff-row computation in a single source of truth prevents the formatter
and the renderer from drifting on edge-case row classification.
### Q: Should we copy a "summary-only" mode if the diff is enormous (1000+ rows)?
No, not for #187. The forensic use case explicitly wants the full row list. If a
practical pain point emerges (a real user reports their paste was truncated by the
target app), we add a second "Copy summary" button alongside the main one. Tracked as
a hypothetical follow-up; not built now.
---
## Out of scope
Items explicitly **not** in this spec; each is a candidate follow-up issue if a real need
emerges:
- **Per-source partial copy buttons** — confirmed full-diff is enough.
- **Compact mode toggle** — including `kept` entries is the default and only behavior.
- **Sharing the diff via `@capacitor/share`** on Android — separate intent, separate
issue.
- **Fallback for clipboard-write failure** — silent swallow today, matches
`ErrorExpansion` precedent.
- **i18n for the other 22 locales** — follow the established newer-keys pattern (en /
es / ar), let `i18nLookup` fall back to English elsewhere.
- **`navigator.share` for plain-text** — possible future "Share diff" button alongside
Copy, but not part of this issue.
---
## Acceptance criteria
- A `Copy` button appears at the top-right of the diff expansion when a `diffDocument`
is present and non-empty.
- A `Copy` button appears on every cleaned-leaf diff inside a ZIP expansion.
- Clicking `Copy` writes a markdown-formatted diff to the clipboard.
- A "Copied to clipboard" toast appears after a successful copy (i18n'd in en / es / ar).
- The clipboard payload format matches the structure documented in this spec
(filename header, total summary, per-source `###` sections, bullet rows with the
per-status pattern, bold field names for `removed`).
- The skeleton and "already clean" expansion states show no Copy button.
- No DOM / CSS collision with PR #189's Share / share-zip buttons.
- All quality gates pass: `yarn typecheck`, `yarn lint`, `yarn test`, `yarn check:deps`,
`yarn build:web`, `yarn build:web:standalone`, `yarn test:e2e:web`.

View file

@ -236,6 +236,8 @@ export function FileRow({
<MetadataDiffExpansion
diffDocument={file.diffDocument}
diffPending={diffPending}
filename={file.name}
onCopyToast={onCopyToast}
/>
) : (
<div className="file-table__expansion">
@ -245,7 +247,11 @@ export function FileRow({
</div>
))}
{isExpanded && isComplete && hasArchiveEntries && (
<ZipExpansion entryId={file.id} entries={file.archiveEntries!} />
<ZipExpansion
entryId={file.id}
entries={file.archiveEntries!}
onCopyToast={onCopyToast}
/>
)}
{isExpanded &&
file.status === FileProcessingStatus.Complete &&
@ -254,6 +260,8 @@ export function FileRow({
<MetadataDiffExpansion
diffDocument={file.diffDocument}
diffPending={diffPending}
filename={file.name}
onCopyToast={onCopyToast}
/>
)}
</div>

View file

@ -103,8 +103,8 @@ export function FileTable(): React.JSX.Element {
}, [t]);
const handleCopyToast = useCallback(() => {
showToast("Copied to clipboard");
}, []);
showToast(t("copyToast"));
}, [t]);
const handleRevealError = useCallback((message: string) => {
showToast(message);

View file

@ -20,14 +20,23 @@
// Skeleton mode: while the async ExifTool read is in flight (diffPending)
// and no diffDocument is on the entry yet, render a wayfinding cue so the
// expansion area isn't blank when the user opens the row early.
//
// Copy button: when `onCopyToast` is provided, the rendered diff exposes
// a floating Copy button that writes a markdown payload (see
// src/web/utils/format_diff_clipboard.ts) to navigator.clipboard and
// fires `onCopyToast` on success.
import { useI18n } from "../../hooks/use_i18n";
import { useCallback } from "react";
import type { MetadataDocument } from "../../../domain";
import { MetadataDiffTable, DiffSkeleton } from "./MetadataDiffTable";
import { useI18n } from "../../hooks/use_i18n";
import { formatDiffForClipboard } from "../../utils/format_diff_clipboard";
import { DiffSkeleton, MetadataDiffTable } from "./MetadataDiffTable";
export function MetadataDiffExpansion({
diffDocument,
diffPending = false,
filename,
onCopyToast,
}: {
diffDocument: MetadataDocument | null;
// True while the out-of-band ExifTool diff build is still in flight.
@ -36,14 +45,38 @@ export function MetadataDiffExpansion({
// row while pending they see the skeleton; if the diff arrives while
// the row is expanded, the skeleton swaps for the two-pane view.
diffPending?: boolean;
filename: string;
onCopyToast?: () => void;
}): React.JSX.Element | null {
const { t } = useI18n();
const { t, locale } = useI18n();
const handleCopy = useCallback((): void => {
if (diffDocument === null) return;
const text = formatDiffForClipboard({
document: diffDocument,
filename,
t,
locale,
});
navigator.clipboard.writeText(text).then(
() => {
onCopyToast?.();
},
() => {
/* clipboard write failed silently — matches ErrorExpansion */
},
);
}, [diffDocument, filename, t, locale, onCopyToast]);
if (
diffDocument != null &&
(diffDocument.before.length > 0 || diffDocument.after.length > 0)
) {
return <MetadataDiffTable document={diffDocument} t={t} />;
// Conditional spread (not `onCopy={undefined}`) because
// exactOptionalPropertyTypes treats `undefined` and "absent" as
// distinct.
const copyProp = onCopyToast !== undefined ? { onCopy: handleCopy } : {};
return <MetadataDiffTable document={diffDocument} t={t} {...copyProp} />;
}
// Diff still in flight — skeleton wayfinding cue.

View file

@ -9,32 +9,46 @@
// - ZipExpansion leaf renders pass "zip-expansion__leaf-diff" to get a
// slimmer wrapper without the file-table expansion padding.
import type { MetadataDocument, MetadataEntry } from "../../../domain";
type DiffRowStatus = "removed" | "added" | "modified" | "kept";
interface DiffRow {
readonly status: DiffRowStatus;
readonly source: string;
readonly name: string;
readonly before: string | null;
readonly after: string | null;
}
import type { MetadataDocument } from "../../../domain";
import {
type DiffRow,
computeDiffRows,
groupRowsBySource,
} from "../../utils/diff_rows";
import { ClipboardIcon } from "../icons/ClipboardIcon";
export function MetadataDiffTable({
document,
t,
wrapperClassName = "file-table__expansion file-table__diff file-table__diff--two-pane",
onCopy,
}: {
document: MetadataDocument;
t: (key: string) => string;
wrapperClassName?: string;
onCopy?: () => void;
}): React.JSX.Element {
const rows = computeDiffRows(document);
const grouped = groupRowsBySource(rows);
return (
<div className={wrapperClassName}>
{onCopy !== undefined && (
<button
type="button"
className="file-table__diff-copy-btn"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
aria-label={t("diffCopyButton")}
>
<ClipboardIcon />
<span className="file-table__diff-copy-btn-label">
{t("diffCopyButton")}
</span>
</button>
)}
<div className="file-table__diff-pane-header">
<span className="file-table__diff-pane-label file-table__diff-pane-label--before">
{t("diffPaneBefore")}
@ -157,71 +171,6 @@ function PaneRow({
);
}
function computeDiffRows(document: MetadataDocument): readonly DiffRow[] {
const afterByKey = new Map<string, MetadataEntry>();
for (const entry of document.after) {
afterByKey.set(makeKey(entry.source, entry.name), entry);
}
const beforeKeys = new Set<string>();
const rows: DiffRow[] = [];
for (const entry of document.before) {
const key = makeKey(entry.source, entry.name);
beforeKeys.add(key);
const after = afterByKey.get(key);
if (after === undefined) {
rows.push({
status: "removed",
source: entry.source,
name: entry.name,
before: entry.value,
after: null,
});
} else if (after.value === entry.value) {
rows.push({
status: "kept",
source: entry.source,
name: entry.name,
before: entry.value,
after: after.value,
});
} else {
rows.push({
status: "modified",
source: entry.source,
name: entry.name,
before: entry.value,
after: after.value,
});
}
}
for (const entry of document.after) {
const key = makeKey(entry.source, entry.name);
if (!beforeKeys.has(key)) {
rows.push({
status: "added",
source: entry.source,
name: entry.name,
before: null,
after: entry.value,
});
}
}
return rows;
}
// NUL separator (not a space, not a colon) so the composed key can't
// collide with a tag name that legitimately contains the separator.
// Tag names from ExifTool -G1 are mostly ASCII identifiers, but spaces
// have shown up in extended XMP namespaces. NUL is forbidden in every
// metadata grammar we route, so it's a safe sentinel.
function makeKey(source: string, name: string): string {
return `${source}\0${name}`;
}
function makePaneGroupSummary(
rows: readonly DiffRow[],
t: (key: string) => string,
@ -257,28 +206,3 @@ function makePaneGroupSummary(
if (parts.length === 0) return "";
return `· ${parts.join(t("diffGroupSeparator"))}`;
}
interface SourceRowGroup {
readonly source: string;
readonly rows: readonly DiffRow[];
}
function groupRowsBySource(
rows: readonly DiffRow[],
): readonly SourceRowGroup[] {
const order: string[] = [];
const byKey = new Map<string, DiffRow[]>();
for (const row of rows) {
const existing = byKey.get(row.source);
if (existing === undefined) {
order.push(row.source);
byKey.set(row.source, [row]);
} else {
existing.push(row);
}
}
return order.map((source) => ({
source,
rows: byKey.get(source) as DiffRow[],
}));
}

View file

@ -12,6 +12,7 @@ import { assertNever } from "../../../common";
import type { ArchiveEntryResult } from "../../../domain";
import { useI18n } from "../../hooks/use_i18n";
import { ChevronIcon } from "../icons/ChevronIcon";
import { formatDiffForClipboard } from "../../utils/format_diff_clipboard";
import { MetadataDiffTable, DiffSkeleton } from "./MetadataDiffTable";
// Pagination — render the first N entries eagerly; surface a button
@ -43,6 +44,7 @@ export function ZipExpansion({
entries,
depth = 0,
pathPrefix = "",
onCopyToast,
}: {
entryId: string;
entries: readonly ArchiveEntryResult[];
@ -55,8 +57,12 @@ export function ZipExpansion({
// in WasmProcessor.stashArchiveLeaves. Without this prefix,
// nested zips with same-named leaves would collide.
pathPrefix?: string;
// When provided, each cleaned-leaf diff renders a Copy button that
// writes the per-leaf markdown to the clipboard and fires this
// callback on success. Threaded recursively to nested archives.
onCopyToast?: () => void;
}): React.JSX.Element {
const { t } = useI18n();
const { t, locale } = useI18n();
const [visible, setVisible] = useState(VISIBLE_PAGE_SIZE);
const [leafStates, setLeafStates] = useState<Map<string, LeafState>>(
new Map(),
@ -128,6 +134,8 @@ export function ZipExpansion({
setLeafState={setLeafState}
setLeafStateIfStill={setLeafStateIfStill}
t={t}
locale={locale}
{...(onCopyToast !== undefined ? { onCopyToast } : {})}
/>
);
})}
@ -154,6 +162,8 @@ function ZipExpansionRow({
setLeafState,
setLeafStateIfStill,
t,
locale,
onCopyToast,
}: {
stateKey: string;
entryId: string;
@ -168,6 +178,8 @@ function ZipExpansionRow({
next: LeafState;
}) => void;
t: (key: string) => string;
locale: string;
onCopyToast?: () => void;
}): React.JSX.Element {
// Both "cleaned" and "already-clean" are processable (the bytes stash
// was populated; the diff is meaningful). The distinction is only for
@ -281,13 +293,14 @@ function ZipExpansionRow({
{isExpanded &&
isCleanedLeaf &&
state.kind === "loaded" &&
renderLeafBody(state.result, t)}
renderLeafBody(state.result, t, locale, fullPath, onCopyToast)}
{isExpanded && isNestedZip && entry.entries !== null && (
<ZipExpansion
entryId={entryId}
entries={entry.entries}
depth={depth + 1}
pathPrefix={`${fullPath}\0`}
{...(onCopyToast !== undefined ? { onCopyToast } : {})}
/>
)}
</div>
@ -301,6 +314,9 @@ function ZipExpansionRow({
function renderLeafBody(
result: LeafDiffResult,
t: (key: string) => string,
locale: string,
fullPath: string,
onCopyToast: (() => void) | undefined,
): React.JSX.Element {
if (result.kind === "failed") {
return (
@ -311,11 +327,31 @@ function renderLeafBody(
}
const { doc } = result;
if (doc.before.length > 0 || doc.after.length > 0) {
const handleCopy =
onCopyToast !== undefined
? (): void => {
const text = formatDiffForClipboard({
document: doc,
filename: leafDisplayPath(fullPath),
t,
locale,
});
navigator.clipboard.writeText(text).then(
() => {
onCopyToast();
},
() => {
/* silent — matches top-level diff behavior */
},
);
}
: undefined;
return (
<MetadataDiffTable
document={doc}
t={t}
wrapperClassName="zip-expansion__leaf-diff file-table__diff--two-pane"
{...(handleCopy !== undefined ? { onCopy: handleCopy } : {})}
/>
);
}
@ -326,6 +362,17 @@ function renderLeafBody(
);
}
// Transforms the NUL-joined composite archive path into a human-readable
// breadcrumb string used in the clipboard markdown header. Top-level
// leaves come through as e.g. "inner/photo.jpg" (no NULs). Nested zips
// produce "outer.zip > inner/photo.jpg".
function leafDisplayPath(fullPath: string): string {
return fullPath
.split("\0")
.filter((segment) => segment.length > 0)
.join(" > ");
}
function renderStatus(
entry: ArchiveEntryResult,
t: (key: string) => string,

View file

@ -0,0 +1,19 @@
export function ClipboardIcon(): React.JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="3" width="8" height="11" rx="1.5" />
<path d="M6 3v-1a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1" />
<path d="M7 7h2M7 9.5h2" />
</svg>
);
}

View file

@ -427,11 +427,16 @@
The before / after panes get distinct background tints to keep the
visual side-by-side metaphor even when individual cells are neutral. */
.file-table__diff--two-pane {
position: relative;
}
.file-table__diff--two-pane .file-table__diff-pane-header {
display: grid;
grid-template-columns: 220px 1fr 1fr;
gap: var(--ec-space-4);
padding: var(--ec-space-2) var(--ec-space-4);
padding-right: 90px; /* space for the absolutely-positioned Copy button */
border-bottom: 1px solid var(--ec-color-border);
background: var(--ec-color-surface);
font-family: var(--ec-font-family);
@ -442,6 +447,74 @@
color: var(--ec-color-text-secondary);
}
/* Floating Copy button top-right of the diff wrapper. Sits above the
pane header on desktop and remains visible when the header collapses
on mobile. */
.file-table__diff-copy-btn {
position: absolute;
top: var(--ec-space-1);
right: var(--ec-space-2);
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 8px;
color: var(--ec-color-text-secondary);
cursor: pointer;
font-family: var(--ec-font-family);
font-size: 12px;
font-weight: var(--ec-font-weight-semibold);
line-height: 1;
transition:
color 120ms ease,
background-color 120ms ease,
border-color 120ms ease;
}
.file-table__diff-copy-btn:hover,
.file-table__diff-copy-btn:focus-visible {
color: var(--ec-color-text-primary);
background-color: var(--ec-color-surface);
border-color: var(--ec-color-border);
outline: none;
}
@media (max-width: 480px) {
.file-table__diff-copy-btn-label {
display: none;
}
.file-table__diff-copy-btn {
padding: 6px;
}
.file-table__diff--two-pane .file-table__diff-pane-header {
padding-right: var(--ec-space-4);
}
}
/* RTL: pin to top-left instead of top-right. */
[dir="rtl"] .file-table__diff-copy-btn {
right: auto;
left: var(--ec-space-2);
}
[dir="rtl"]
.file-table__diff--two-pane
.file-table__diff-pane-header {
padding-right: var(--ec-space-4);
padding-left: 90px;
}
@media (max-width: 480px) {
[dir="rtl"]
.file-table__diff--two-pane
.file-table__diff-pane-header {
padding-left: var(--ec-space-4);
}
}
.file-table__diff--two-pane .file-table__diff-pane-label {
min-width: 0;
}

105
src/web/utils/diff_rows.ts Normal file
View file

@ -0,0 +1,105 @@
// Shared diff-row computation, extracted from MetadataDiffTable so the
// clipboard formatter (src/web/utils/format_diff_clipboard.ts) can reuse
// the same row-classification logic without depending on the React
// component.
import type { MetadataDocument, MetadataEntry } from "../../domain";
export type DiffRowStatus = "removed" | "added" | "modified" | "kept";
export interface DiffRow {
readonly status: DiffRowStatus;
readonly source: string;
readonly name: string;
readonly before: string | null;
readonly after: string | null;
}
export interface SourceRowGroup {
readonly source: string;
readonly rows: readonly DiffRow[];
}
export function computeDiffRows(
document: MetadataDocument,
): readonly DiffRow[] {
const afterByKey = new Map<string, MetadataEntry>();
for (const entry of document.after) {
afterByKey.set(makeKey(entry.source, entry.name), entry);
}
const beforeKeys = new Set<string>();
const rows: DiffRow[] = [];
for (const entry of document.before) {
const key = makeKey(entry.source, entry.name);
beforeKeys.add(key);
const after = afterByKey.get(key);
if (after === undefined) {
rows.push({
status: "removed",
source: entry.source,
name: entry.name,
before: entry.value,
after: null,
});
} else if (after.value === entry.value) {
rows.push({
status: "kept",
source: entry.source,
name: entry.name,
before: entry.value,
after: after.value,
});
} else {
rows.push({
status: "modified",
source: entry.source,
name: entry.name,
before: entry.value,
after: after.value,
});
}
}
for (const entry of document.after) {
const key = makeKey(entry.source, entry.name);
if (!beforeKeys.has(key)) {
rows.push({
status: "added",
source: entry.source,
name: entry.name,
before: null,
after: entry.value,
});
}
}
return rows;
}
export function groupRowsBySource(
rows: readonly DiffRow[],
): readonly SourceRowGroup[] {
const order: string[] = [];
const byKey = new Map<string, DiffRow[]>();
for (const row of rows) {
const existing = byKey.get(row.source);
if (existing === undefined) {
order.push(row.source);
byKey.set(row.source, [row]);
} else {
existing.push(row);
}
}
return order.map((source) => ({
source,
rows: byKey.get(source) as DiffRow[],
}));
}
// NUL separator (not a space, not a colon) so the composed key can't
// collide with a tag name that legitimately contains the separator.
function makeKey(source: string, name: string): string {
return `${source}\0${name}`;
}

View file

@ -0,0 +1,198 @@
// Pure markdown formatter for the metadata diff. Used by both the
// top-level MetadataDiffExpansion and per-leaf renders inside
// ZipExpansion to produce the clipboard payload when the Copy button
// is clicked.
//
// Format contract is documented in
// docs/superpowers/specs/2026-05-22-issue-187-copy-diff-clipboard-design.md.
import type { MetadataDocument } from "../../domain";
import { type DiffRow, computeDiffRows, groupRowsBySource } from "./diff_rows";
// Locales whose markdown destinations default to RTL paragraph
// direction. When the destination is RTL, mixed-bidi content like
// `- **Author**: `J.K. Rowling` → (تمت إزالته)`
// renders with jarring directional jumps — the parenthesised Arabic
// label, the arrow, and the LTR value get reordered by the bidi
// algorithm and the result is hard to scan. For RTL locales we wrap
// every non-empty line in Unicode bidi isolates so each line resolves
// as LTR-base regardless of surrounding paragraph direction. The
// isolates are zero-width invisible characters in every modern
// renderer (browsers, terminals, chat apps), so plain-text paste
// destinations don't see literal HTML tags — unlike the alternative
// of wrapping the payload in a `<div dir="ltr">` block.
const RTL_LOCALES: ReadonlySet<string> = new Set(["ar"]);
const LRI = ""; // U+2066 — Left-to-Right Isolate
const PDI = ""; // U+2069 — Pop Directional Isolate
function isRTL(locale: string): boolean {
const base = locale.split("-")[0] ?? locale;
return RTL_LOCALES.has(base);
}
// Wrap the line content in LRI…PDI so a Forgejo / GitHub Arabic context
// resolves it as an LTR run.
//
// IMPORTANT: markdown block syntax (`### ` for headings, `- ` for list
// items) must remain at column 0 for the parser to recognize it.
// Putting LRI at position 0 of the line pushes `#` to position 1 and
// the heading silently degrades to a paragraph. So for these prefixes
// we wrap only the content after the marker; otherwise we wrap the
// whole line.
function wrapLineLTR(line: string): string {
if (line.length === 0) return line;
const headingMatch = line.match(/^(#{1,6} )(.*)$/);
if (headingMatch !== null) {
return `${headingMatch[1]}${LRI}${headingMatch[2]}${PDI}`;
}
const bulletMatch = line.match(/^(- )(.*)$/);
if (bulletMatch !== null) {
return `${bulletMatch[1]}${LRI}${bulletMatch[2]}${PDI}`;
}
return `${LRI}${line}${PDI}`;
}
export function formatDiffForClipboard({
document,
filename,
t,
locale = "en",
}: {
document: MetadataDocument;
filename: string;
t: (key: string) => string;
locale?: string;
}): string {
const rows = computeDiffRows(document);
const grouped = groupRowsBySource(rows);
const lines: string[] = [];
lines.push(`**${t("diffClipboardHeader")}** — ${codeWrap(filename)}`);
const total = renderSummary(countByStatus(rows), t);
if (total.length > 0) lines.push(total);
lines.push("");
for (const group of grouped) {
const groupSummary = renderSummary(countByStatus(group.rows), t);
const sourceLabel = escapeMdInline(group.source);
const heading =
groupSummary.length > 0
? `### ${sourceLabel} · ${groupSummary}`
: `### ${sourceLabel}`;
lines.push(heading);
for (const row of group.rows) {
lines.push(renderBullet(row, t));
}
lines.push("");
}
const body = `${lines.join("\n").trimEnd()}\n`;
// Strip pre-existing bidi control codepoints from the assembled
// payload. These could come from adversarial or unusual metadata
// values (e.g. an EXIF Artist field carrying U+2069 / PDI) and would
// otherwise prematurely close our own LRI/PDI wrapping in the RTL
// path, or surface as invisible noise in any destination's bidi
// resolution. They serve no user purpose in a clipboard payload.
const sanitized = body.replace(/[--]/g, "");
if (!isRTL(locale)) return sanitized;
return sanitized.split("\n").map(wrapLineLTR).join("\n");
}
interface StatusCounts {
removed: number;
modified: number;
added: number;
kept: number;
}
function countByStatus(rows: readonly DiffRow[]): StatusCounts {
const out: StatusCounts = { removed: 0, modified: 0, added: 0, kept: 0 };
for (const row of rows) out[row.status] += 1;
return out;
}
function renderSummary(
counts: StatusCounts,
t: (key: string) => string,
): string {
const sep = t("diffGroupSeparator");
const parts: string[] = [];
if (counts.removed > 0)
parts.push(
t("diffGroupRemoved").replace("{count}", String(counts.removed)),
);
if (counts.modified > 0)
parts.push(
t("diffGroupModified").replace("{count}", String(counts.modified)),
);
if (counts.added > 0)
parts.push(t("diffGroupAdded").replace("{count}", String(counts.added)));
if (counts.kept > 0)
parts.push(t("diffGroupKept").replace("{count}", String(counts.kept)));
return parts.join(sep);
}
function renderBullet(row: DiffRow, t: (key: string) => string): string {
const name = escapeMdInline(row.name);
switch (row.status) {
case "removed":
return `- **${name}**: ${codeWrap(row.before ?? "")} → (${t("diffClipboardRemoved")})`;
case "added":
return `- ${name}: (${t("diffClipboardNone")}) → ${codeWrap(row.after ?? "")}`;
case "modified":
return `- ${name}: ${codeWrap(row.before ?? "")}${codeWrap(row.after ?? "")}`;
case "kept":
return `- ${name}: ${codeWrap(row.before ?? "")} (${t("diffClipboardKept")})`;
default:
return exhaustive(row.status);
}
}
// CommonMark inline-code rule: find the longest run of N consecutive
// backticks in the value, then wrap with (N+1)-backtick delimiters. Pad
// with a leading/trailing space when the value itself begins or ends
// with a backtick (the parser strips one leading + one trailing space
// inside a code span). Newlines are escaped to keep the span on one
// line so the markdown stays valid in destinations that don't accept
// multi-line code spans (most chat renderers, plain-text paste).
function codeWrap(raw: string): string {
const escaped = raw.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
const longestBacktickRun = findLongestBacktickRun(escaped);
if (longestBacktickRun === 0) return `\`${escaped}\``;
const fence = "`".repeat(longestBacktickRun + 1);
const startsOrEndsWithBacktick =
escaped.startsWith("`") || escaped.endsWith("`");
const pad = startsOrEndsWithBacktick ? " " : "";
return `${fence}${pad}${escaped}${pad}${fence}`;
}
function findLongestBacktickRun(s: string): number {
let longest = 0;
let current = 0;
for (const ch of s) {
if (ch === "`") {
current += 1;
if (current > longest) longest = current;
} else {
current = 0;
}
}
return longest;
}
// Escape every character that has inline-markdown meaning so a field
// name or source label can't accidentally trigger emphasis (`*`, `_`),
// link / reference syntax (`[`, `]`), raw-HTML / autolink (`<`, `>`),
// a code span (`` ` ``), or an escape sequence (`\`). Code-span values
// are wrapped separately via codeWrap and don't need this treatment.
// ExifTool tag names are usually safe ASCII identifiers, but custom
// XMP qualified names, PDF Info-dict keys, and Office walker source
// labels can include any of these characters.
function escapeMdInline(raw: string): string {
return raw.replace(/[\\*_`[\]<>]/g, "\\$&");
}
function exhaustive(value: never): never {
throw new Error(`Unhandled diff row status: ${String(value)}`);
}

View file

@ -0,0 +1,81 @@
// Copy diff to clipboard — Web (issue #187)
//
// Drops sample.jpg, waits for the row to become expandable, expands the
// diff, clicks the Copy button, and verifies:
//
// 1. The "Copied to clipboard" toast appears.
// 2. navigator.clipboard.readText() contains the expected markdown
// payload (filename header, ### source heading, at least one
// bullet row).
//
// WebKit is skipped — Playwright's WebKit driver does not grant
// clipboard-read without a user prompt, even with permission grants in
// the context. The clipboard *write* itself works on WebKit (component
// test in tests/web exercises that path); this is purely a read-side
// limitation of the test harness.
//
// Standalone Chromium project is skipped too — Playwright won't grant
// clipboard permissions on a file:// origin.
import { expect, test } from "@playwright/test";
import { fixturePath } from "./helpers/fixture_loader";
import { launchPage } from "./helpers/page_launcher";
test.describe("Copy diff to clipboard", () => {
test.beforeEach(async ({ page, context, browserName }, testInfo) => {
test.skip(
browserName === "webkit",
"WebKit driver — clipboard-read grant doesn't bypass the user prompt under Playwright.",
);
test.skip(
testInfo.project.name.startsWith("standalone-"),
"Standalone (file:// origin) — Playwright cannot grant clipboard permissions on file://.",
);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await launchPage(page);
});
test("clicks Copy and writes markdown to the clipboard", async ({
page,
isMobile,
}) => {
test.setTimeout(45_000);
const input = page.locator(".file-browse-button__input").first();
await input.setInputFiles([fixturePath("sample.jpg")], { force: true });
const row = page.locator(".file-table__row--complete").first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toHaveClass(/file-table__row--expandable/);
if (isMobile) {
await row.tap();
} else {
await row.click();
}
const diff = page.locator(".file-table__diff--two-pane");
await expect(diff).toBeVisible();
const copyBtn = diff.locator(".file-table__diff-copy-btn");
await expect(copyBtn).toBeVisible();
if (isMobile) {
await copyBtn.tap();
} else {
await copyBtn.click();
}
const toast = page.locator(".toast--visible");
await expect(toast).toContainText(/Copied to clipboard/i, {
timeout: 5_000,
});
const clipboard = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(clipboard).toContain("**Metadata diff** — `sample.jpg`");
expect(clipboard).toMatch(/^### /m);
expect(clipboard).toMatch(/^- /m);
});
});

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { MetadataDocument } from "../../../src/domain";
import {
computeDiffRows,
groupRowsBySource,
} from "../../../src/web/utils/diff_rows";
describe("computeDiffRows", () => {
it("classifies removed / kept / modified / added", () => {
const doc: MetadataDocument = {
before: [
{ source: "EXIF", name: "GPSLatitude", value: "37.0" },
{ source: "EXIF", name: "Orientation", value: "1" },
{ source: "EXIF", name: "Software", value: "Lightroom" },
],
after: [
{ source: "EXIF", name: "Orientation", value: "1" },
{ source: "EXIF", name: "Software", value: "MetaScrub" },
{ source: "EXIF", name: "ColorSpace", value: "sRGB" },
],
};
const rows = computeDiffRows(doc);
expect(rows.map((r) => [r.name, r.status])).toEqual([
["GPSLatitude", "removed"],
["Orientation", "kept"],
["Software", "modified"],
["ColorSpace", "added"],
]);
});
it("returns no rows when both panes are empty", () => {
expect(computeDiffRows({ before: [], after: [] })).toEqual([]);
});
});
describe("groupRowsBySource", () => {
it("preserves first-seen source order", () => {
const doc: MetadataDocument = {
before: [
{ source: "XMP", name: "Rating", value: "3" },
{ source: "EXIF", name: "Make", value: "Canon" },
{ source: "XMP", name: "Creator", value: "Jane" },
],
after: [],
};
const groups = groupRowsBySource(computeDiffRows(doc));
expect(groups.map((g) => g.source)).toEqual(["XMP", "EXIF"]);
expect(groups[0]!.rows).toHaveLength(2);
expect(groups[1]!.rows).toHaveLength(1);
});
});

View file

@ -0,0 +1,302 @@
import { describe, expect, it } from "vitest";
import type { MetadataDocument } from "../../../src/domain";
import { formatDiffForClipboard } from "../../../src/web/utils/format_diff_clipboard";
// Identity translator — returns the key. Lets the assertions focus on
// structural shape, not on string content.
const tId = (key: string): string => key;
// Convenience builder.
const doc = (
before: ReadonlyArray<[string, string, string]>,
after: ReadonlyArray<[string, string, string]>,
): MetadataDocument => ({
before: before.map(([source, name, value]) => ({ source, name, value })),
after: after.map(([source, name, value]) => ({ source, name, value })),
});
describe("formatDiffForClipboard", () => {
it("renders the header line with the filename", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Make", "Canon"]], [["EXIF", "Make", "Canon"]]),
filename: "IMG_1234.jpg",
t: tId,
});
expect(out.split("\n")[0]).toBe(
"**diffClipboardHeader** — `IMG_1234.jpg`",
);
});
it("renders the total summary line on line 2, skipping zero counts", () => {
const out = formatDiffForClipboard({
document: doc(
[
["EXIF", "GPS", "37.0"],
["EXIF", "Make", "Canon"],
],
[["EXIF", "Make", "Canon"]],
),
filename: "f.jpg",
t: tId,
});
expect(out.split("\n")[1]).toBe(
"diffGroupRemoveddiffGroupSeparatordiffGroupKept",
);
});
it("formats a removed row with bold field name and (removed) suffix", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "GPS", "37.0"]], []),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("- **GPS**: `37.0` → (diffClipboardRemoved)");
});
it("formats an added row without bold, with (none) placeholder", () => {
const out = formatDiffForClipboard({
document: doc([], [["EXIF", "ColorSpace", "sRGB"]]),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("- ColorSpace: (diffClipboardNone) → `sRGB`");
});
it("formats a modified row with X → Y notation, no bold", () => {
const out = formatDiffForClipboard({
document: doc([["XMP", "Rating", "4"]], [["XMP", "Rating", "5"]]),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("- Rating: `4` → `5`");
});
it("formats a kept row with (kept) suffix, no bold", () => {
const out = formatDiffForClipboard({
document: doc(
[["EXIF", "ColorSpace", "sRGB"]],
[["EXIF", "ColorSpace", "sRGB"]],
),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("- ColorSpace: `sRGB` (diffClipboardKept)");
});
it("groups by source with ### heading + per-source summary", () => {
const out = formatDiffForClipboard({
document: doc(
[
["EXIF", "GPS", "37.0"],
["XMP", "Creator", "Jane"],
],
[],
),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("### EXIF · diffGroupRemoved");
expect(out).toContain("### XMP · diffGroupRemoved");
});
it("escapes embedded backticks with double-backtick wrapper", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Comment", "weird`value"]], []),
filename: "f.jpg",
t: tId,
});
expect(out).toContain(
"- **Comment**: ``weird`value`` → (diffClipboardRemoved)",
);
});
it("escalates the fence for values with multiple consecutive backticks", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Comment", "a``b"]], []),
filename: "f.jpg",
t: tId,
});
// "a``b" has a 2-backtick run → fence must be 3 backticks.
expect(out).toContain(
"- **Comment**: ```a``b``` → (diffClipboardRemoved)",
);
});
it("pads the fence when the value starts or ends with a backtick", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Comment", "`foo"]], []),
filename: "f.jpg",
t: tId,
});
// Value starts with backtick → pad with a leading space inside the
// 2-backtick fence so the parser doesn't read the inner backtick as
// part of the delimiter.
expect(out).toContain(
"- **Comment**: `` `foo `` → (diffClipboardRemoved)",
);
});
it("wraps the filename via codeWrap so backticks in the filename don't corrupt the header", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Make", "Canon"]], [["EXIF", "Make", "Canon"]]),
filename: "weird`name.jpg",
t: tId,
});
expect(out.split("\n")[0]).toBe(
"**diffClipboardHeader** — ``weird`name.jpg``",
);
});
it("escapes inline-markdown characters in field names across every status", () => {
const out = formatDiffForClipboard({
document: doc(
[
["XMP", "Foo*Bar", "a"],
["XMP", "Foo_Bar", "b"],
["XMP", "Foo[Bar]", "c"],
],
[
["XMP", "Foo_Bar", "b"],
["XMP", "Foo[Bar]", "c2"],
["XMP", "<Foo>", "d"],
],
),
filename: "f.jpg",
t: tId,
});
// Removed: bold + escaped name.
expect(out).toContain(
"- **Foo\\*Bar**: `a` → (diffClipboardRemoved)",
);
// Kept: plain + escaped name (otherwise '_Bar' would render italic).
expect(out).toContain("- Foo\\_Bar: `b` (diffClipboardKept)");
// Modified: plain + escaped name (otherwise '[Bar]' could parse as a link).
expect(out).toContain("- Foo\\[Bar\\]: `c` → `c2`");
// Added: plain + escaped name (otherwise '<Foo>' could parse as raw HTML).
expect(out).toContain("- \\<Foo\\>: (diffClipboardNone) → `d`");
});
it("escapes inline-markdown characters in source headings", () => {
const out = formatDiffForClipboard({
document: doc([["Office_Custom*", "Foo", "bar"]], []),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("### Office\\_Custom\\* ·");
});
it("strips bidi control codepoints from values so they don't pop the outer isolate", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Artist", "goodbad"]], []),
filename: "f.jpg",
t: tId,
locale: "ar",
});
// The U+2069 inside the value would otherwise close our LRI wrap.
// After sanitization there is exactly one LRI (per non-empty line)
// and one PDI per line — never any inside a value.
expect(out).not.toContain("goodbad");
expect(out).toContain("goodbad");
});
it("escapes newlines in values as visible \\n", () => {
const out = formatDiffForClipboard({
document: doc([["XMP", "Desc", "line1\nline2"]], []),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("`line1\\nline2`");
expect(out).not.toMatch(/line1\nline2/);
});
it("ends with exactly one trailing newline", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "GPS", "37.0"]], []),
filename: "f.jpg",
t: tId,
});
expect(out.endsWith("\n")).toBe(true);
expect(out.endsWith("\n\n")).toBe(false);
});
it("handles an empty document without crashing", () => {
const out = formatDiffForClipboard({
document: doc([], []),
filename: "f.jpg",
t: tId,
});
expect(out).toContain("**diffClipboardHeader** — `f.jpg`");
});
it("wraps each non-empty line in U+2066/U+2069 bidi isolates for RTL locales", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Make", "Canon"]], []),
filename: "f.jpg",
t: tId,
locale: "ar",
});
const lines = out.split("\n");
const contentLines = lines.filter((l) => l.length > 0);
// Every non-empty line ends with U+2069 (PDI).
for (const line of contentLines) {
expect(line.endsWith("")).toBe(true);
}
// Heading lines must KEEP `### ` at column 0 — otherwise the markdown
// parser doesn't recognize them as headings. Wrap only the content
// AFTER the marker. Same for bullet lines (`- `).
for (const line of contentLines) {
if (line.startsWith("### ")) expect(line).toMatch(/^### /);
else if (line.startsWith("- ")) expect(line).toMatch(/^- /);
else expect(line.startsWith("")).toBe(true);
}
// Blank lines between sections are preserved as-is.
expect(lines).toContain("");
// No visible HTML wrapper.
expect(out).not.toContain("<div");
expect(out).not.toContain("</div>");
});
it("does not wrap lines for LTR locales", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Make", "Canon"]], []),
filename: "f.jpg",
t: tId,
locale: "en",
});
expect(out).not.toContain("");
expect(out).not.toContain("");
});
it("treats a regional Arabic variant (ar-EG) as RTL", () => {
const out = formatDiffForClipboard({
document: doc([["EXIF", "Make", "Canon"]], []),
filename: "f.jpg",
t: tId,
locale: "ar-EG",
});
expect(out).toContain("");
});
it("renders all four statuses in a single source block", () => {
const out = formatDiffForClipboard({
document: doc(
[
["EXIF", "GPS", "37.0"],
["EXIF", "Orientation", "1"],
["EXIF", "Software", "Lightroom"],
],
[
["EXIF", "Orientation", "1"],
["EXIF", "Software", "MetaScrub"],
["EXIF", "ColorSpace", "sRGB"],
],
),
filename: "mixed.jpg",
t: tId,
});
expect(out).toContain("- **GPS**: `37.0` → (diffClipboardRemoved)");
expect(out).toContain("- Orientation: `1` (diffClipboardKept)");
expect(out).toContain("- Software: `Lightroom` → `MetaScrub`");
expect(out).toContain("- ColorSpace: (diffClipboardNone) → `sRGB`");
});
});