Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .igniter/workflow/deliver.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Deliver

Deliver one owner-approved checkpoint to `main` through GitHub. The pull
request, required `Project check`, native rebase auto-merge, and landed commit
are one delivery; do not stop after opening the pull request.
request, required `Project check`, native rebase auto-merge, landed commit,
and production website deployments are one delivery; do not stop after opening
the pull request or merging it.

## Prepare

Expand Down Expand Up @@ -36,12 +37,30 @@ are one delivery; do not stop after opening the pull request.
- If `main` advances before merge, rebase onto the new `origin/main`, rerun
`pnpm check`, push with `--force-with-lease`, and watch the replacement check.

## Publish the websites

- After merge, fetch and verify the landed commit on `origin/main`. Deploy
from that clean landed revision, never from unmerged feature changes.
- Use the Cloudflare and Wrangler skills. Read credentials from environment
variables; keep authentication noninteractive and never print tokens.
- Run `pnpm run deploy` to build and deploy the `diffwalk-reports` Worker and
shared report assets at `https://review.diffwalk.dev`.
- Run `pnpm exec wrangler deploy --config website/wrangler.jsonc` to deploy
the `diffwalk-site` website at `https://diffwalk.dev`.
- Verify the homepage and an existing published review over HTTPS, including
the delivered feature and its shared assets. Use read-only checks; do not
create or delete production review data for deployment verification.
- Record both deployment version IDs, source commit, production URLs, and
verification results. A failed deployment or verification leaves delivery
incomplete; report the concrete blocker.

## Finish

After merge, fetch and verify that remote `main` contains the landed commit.
Record the pull request URL, accepted checkpoint, any rebased checkpoint,
successful check run, auto-merge result, landed commit, final lineage,
working-tree state, remaining owner actions, and blockers or `none`.
website deployment versions and verification, working-tree state, remaining
owner actions, and blockers or `none`.

Do not deploy the Cloudflare Worker or publish the npm package unless the owner
separately authorizes it.
Do not publish the npm package or run infrastructure provisioning unless the
owner separately authorizes it.
27 changes: 27 additions & 0 deletions src/report/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function mountReport(
let finishInitialRender!: () => void
const initialRender = new Promise<void>((resolve) => (finishInitialRender = resolve))
wireSectionFolds()
wireCopyPaths()
wireFragments(initialRender)
const mountedDiffs = mountDiffs(data, layout, createFileDiff)
void mountedDiffs.initialRender.then(finishInitialRender)
Expand Down Expand Up @@ -287,6 +288,32 @@ function wireSectionFolds() {
})
}

function wireCopyPaths() {
document.querySelectorAll<HTMLFormElement>('[data-copy-path-form]').forEach((form) => {
form.addEventListener('click', (event) => event.stopPropagation())
form.addEventListener('submit', async (event) => {
event.preventDefault()
event.stopPropagation()
const button = form.querySelector<HTMLButtonElement>('[data-copy-path]')
if (!button) return
event.preventDefault()
const path = button.dataset.copyPath
if (path === undefined) return
try {
await navigator.clipboard.writeText(path)
} catch {
return
}
button.dataset.copyState = 'copied'
button.setAttribute('aria-label', `Copied relative path ${path}`)
setTimeout(() => {
button.dataset.copyState = 'ready'
button.setAttribute('aria-label', `Copy relative path ${path}`)
}, 1500)
})
})
}

function wireFragments(initialRender: Promise<void>) {
let rendersComplete = false
let finalRevealPending = false
Expand Down
49 changes: 38 additions & 11 deletions src/report/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,16 @@ function renderSection(
const pureRename = file.type === 'rename-pure' && file.hunks.length === 0
const label = escapeHtml(fileDiffLabel(file))
if (pureRename) {
return `<div class="file file-static">
<div class="file-summary">${label} <span class="file-stats">Renamed · content unchanged</span></div>
return `<div class="file-with-copy"><div class="file file-static">
<div class="file-summary">${renderFileLabel(label, 'Renamed · content unchanged', file.name)}</div>
</div>
</div>`
}
return `<details class="file">
<summary class="file-summary">${label} <span class="file-stats">+${stats.additions} −${stats.deletions}</span></summary>
return `<div class="file-with-copy"><details class="file">
<summary class="file-summary">${renderFileLabel(label, `+${stats.additions} −${stats.deletions}`, file.name)}</summary>
<div class="file-diff" data-diff-mount="${index}-${stepIndex}-${fileIndex}"></div>
</details>`
</details>
</div>`
})
.join('\n')

Expand Down Expand Up @@ -224,15 +226,24 @@ function renderBinaryCard(change: BinaryChangeBlock): string {
const moved = movedStatusLabel(change.status)
const stats = moved === undefined ? `Binary · ${change.status}` : `${moved} · excluded content omitted`
const excluded = excludedSide(change.status)
return `<div class="file file-binary">
<div class="file-summary">${escapeHtml(label)} <span class="file-stats">${escapeHtml(stats)}</span></div>
return `<div class="file-with-copy"><div class="file file-binary">
<div class="file-summary">${renderFileLabel(escapeHtml(label), stats, change.path)}</div>
<dl class="binary-sides">
${renderBinarySide('Before', change.before, excluded === 'old')}
${renderBinarySide('After', change.after, excluded === 'new')}
</dl>
</div>
</div>`
}

function renderFileLabel(label: string, stats: string, path: string): string {
return `<div class="file-summary-content"><span class="file-name">${label}</span> <span class="file-stats">${escapeHtml(stats)}</span>${renderCopyForm(path)}</div>`
}

function renderCopyForm(path: string): string {
return `<form class="copy-path-form" data-copy-path-form><button type="submit" class="copy-path" data-copy-path="${escapeHtml(path)}" data-copy-state="ready" aria-label="Copy relative path ${escapeHtml(path)}" title="Copy relative path"><span class="copy-icon copy-icon-copy" aria-hidden="true"><svg viewBox="0 0 16 16" focusable="false"><path d="M5 5V3.5A1.5 1.5 0 0 1 6.5 2h6A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H11"/><rect x="2" y="5" width="9" height="9" rx="1.5"/></svg></span><span class="copy-icon copy-icon-success" aria-hidden="true"><svg viewBox="0 0 16 16" focusable="false"><path d="m3 8 3 3 7-7"/></svg></span></button></form>`
}

function renderBinarySide(label: string, side: BinaryChangeBlock['before'], excluded: boolean): string {
const detail = excluded
? 'excluded'
Expand Down Expand Up @@ -490,7 +501,7 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; }
.section-fold-controls { display: flex; gap: 6px; margin-left: auto; font-size: 12px; font-weight: 500; }
.section-fold-all { border: 1px solid #cbd7cd; border-radius: 5px; padding: 4px 7px; color: #4d6654; background: #f7faf7; cursor: pointer; font: inherit; white-space: nowrap; }
.section-fold-all:hover { color: var(--accent); border-color: #8eaa95; background: #eef5ef; }
.section-toggle:focus-visible, .section-title-text:focus-visible, .section-fold-all:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.section-toggle:focus-visible, .section-title-text:focus-visible, .section-fold-all:focus-visible, .copy-path:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.section-fold > summary::-webkit-details-marker { display: none; }
.section-fold[open] > summary { border-bottom-color: var(--border); }
.prose { color: #3c4d41; font-size: 14px; }
Expand Down Expand Up @@ -571,15 +582,29 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; }
color: #314439;
background: #f3f7f3;
}
.file-with-copy { min-width: 0; }
.file-summary-content { display: flex; align-items: center; gap: 10px; min-width: 0; width: 100%; line-height: 1.35; }
.file-name { min-width: 0; overflow-wrap: anywhere; }
.copy-path-form { display: inline-flex; flex: none; margin: 0; }
.copy-path { display: inline-grid; place-items: center; width: 23px; height: 21px; padding: 3px; border: 0; border-radius: 4px; color: #53665a; background: transparent; cursor: pointer; line-height: 1; }
.copy-icon { display: block; width: 14px; height: 14px; }
.copy-icon svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; }
.copy-icon-success { display: none; }
.copy-path[data-copy-state="copied"] .copy-icon-copy { display: none; }
.copy-path[data-copy-state="copied"] .copy-icon-success { display: block; color: var(--accent); }
.copy-path:hover { color: var(--accent); background: #eef5ef; }
.file > summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
list-style: none;
user-select: none;
}
.file > summary::-webkit-details-marker { display: none; }
.file > summary::before { content: "▸ "; }
.file[open] > summary::before { content: "▾ "; }
.file-stats { float: right; color: #6d7d72; }
.file > summary::before { flex: none; content: "▸"; }
.file[open] > summary::before { content: "▾"; }
.file-stats { flex: none; margin-left: auto; color: #6d7d72; white-space: nowrap; }
.binary-sides { display: grid; gap: 4px; margin: 0; padding: 10px 12px; border-top: 1px solid #d2ddd4; }
.binary-side { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
.binary-side dt { color: #7e8d82; font-weight: 600; }
Expand Down Expand Up @@ -625,6 +650,8 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; }
.layout-form label { padding: 4px 7px; font-size: 11px; }
.fold-all { padding: 4px 7px; font-size: 11px; }
.section-fold > summary { font-size: 17px; }
.file-summary-content { gap: 7px; }
.copy-path { width: 22px; height: 20px; }
}
@media print {
.layout-form, .permalink { display: none; }
Expand Down
53 changes: 49 additions & 4 deletions test/report-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ afterEach(() => {
windows.splice(0).forEach((dom) => dom.happyDOM.cancelAsync())
})

function simplePatch(oldLine = 'old', newLine = 'new'): string {
function simplePatch(oldLine = 'old', newLine = 'new', path = 'example.ts'): string {
return [
'diff --git a/example.ts b/example.ts',
'--- a/example.ts',
'+++ b/example.ts',
`diff --git a/${path} b/${path}`,
`--- a/${path}`,
`+++ b/${path}`,
'@@ -1 +1 @@',
`-${oldLine}`,
`+${newLine}`,
Expand Down Expand Up @@ -822,6 +822,51 @@ describe('report browser client', () => {
expect(button.getAttribute('aria-expanded')).toBe('true')
})

test('copies a relative file path through a form without toggling its file fold', async () => {
const dom = loadReport(renderReport(document([section(simplePatch('old', 'new', 'src/file.ts'), 'Copy path')]), clientBundle))
const writes: string[] = []
Object.defineProperty(dom.window.navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value: string) => writes.push(value) },
})
const doc = dom.document as unknown as Document
runReportClient()

const fold = doc.querySelector<HTMLDetailsElement>('details.file')!
fold.open = true
const form = doc.querySelector<HTMLFormElement>('[data-copy-path-form]')!
const button = form.querySelector<HTMLButtonElement>('button')!
expect(form.method).toBe('get')
expect(button.type).toBe('submit')
expect(button.getAttribute('aria-label')).toBe('Copy relative path src/file.ts')

button.focus()
form.requestSubmit()
await waitFor(() => writes.length === 1)

expect(writes).toEqual(['src/file.ts'])
expect(fold.open).toBe(true)
expect(button.dataset.copyState).toBe('copied')
expect(button.getAttribute('aria-label')).toBe('Copied relative path src/file.ts')
})

test('does not show copy success when the clipboard rejects the write', async () => {
const dom = loadReport(renderReport(document([section(simplePatch(), 'Copy failure')]), clientBundle))
Object.defineProperty(dom.window.navigator, 'clipboard', {
configurable: true,
value: { writeText: async () => { throw new Error('clipboard unavailable') } },
})
const doc = dom.document as unknown as Document
runReportClient()

const button = doc.querySelector<HTMLButtonElement>('[data-copy-path]')!
button.form!.requestSubmit()
await new Promise((resolve) => setTimeout(resolve, 0))

expect(button.dataset.copyState).toBe('ready')
expect(button.getAttribute('aria-label')).toBe('Copy relative path example.ts')
})

test(
'narrow viewport defaults split reports to unified but preserves explicit switching',
async () => {
Expand Down
27 changes: 27 additions & 0 deletions test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,33 @@ describe('renderReport shell', () => {
expect(html).toContain('data-diff-mount="0-0-0"')
})

test('renders an accessible relative-path copy form beside each file name', () => {
const html = renderReport(
document([
section(
[
'diff --git a/src/file.ts b/src/file.ts',
'--- a/src/file.ts',
'+++ b/src/file.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'',
].join('\n'),
'Copy path',
),
]),
stubClient,
)

expect(html).toContain('<form class="copy-path-form" data-copy-path-form>')
expect(html).toContain('data-copy-path="src/file.ts"')
expect(html).toContain('aria-label="Copy relative path src/file.ts"')
expect(html).toContain('class="copy-icon copy-icon-copy"')
expect(html).not.toContain('>Copy</button>')
expect(html).not.toContain('data-copy-path="/home/')
})

test('steps interleave text and diffs in the order they were authored', () => {
const html = renderReport(
document([
Expand Down
Binary file modified test/visual/__screenshots__/desktop/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading