Code Walk

code-walk

Guided code walkthroughs that point at git instead of pasting code. A walk is a Markdown file whose code blocks are git show and git diff arguments. Code Walk resolves them against the repo and renders a clean page with syntax highlighting, readable diffs, collapsible context, line links and review comments.

It's built for working with AI assistants. Ask Claude Code for "a walkthrough of this branch" and it writes a short walk and opens it in your browser. You read it, leave comments on the lines you have questions about, and Claude answers them in place.

This page is a code walk. Every snippet below is a git reference into the code-walk repo, and a workflow renders the page with code-walk build on every push. Its source is docs/index.md.

Get started

Install the CLI (Node 20 or newer):

npm install -g github:jeffbaumes/code-walk

Then add the Claude Code plugin, which teaches Claude the format and the review workflow:

claude plugin marketplace add jeffbaumes/code-walk
claude plugin install code-walk@code-walk

Now ask Claude for a tour of a branch, a PR, a commit, or "how does X work". To see every block type in a sample repo first, clone this repo and run npm run demo.

A walk is Markdown with git references

Here's the kind of file Claude writes. Prose explains; each fenced block names code by commit and path instead of copying it:

---
title: How references are found
---

Any fenced block whose info string starts with `show` or `diff` is a git reference.

```show 5c288d0:src/render.js L16-19
```

code-walk serve renders that block from the repo, like this:

src/render.js
5c288d0git show 5c288d0:src/render.js
1// Markdown → HTML with git-referenced snippets.
2import MarkdownIt from 'markdown-it';
3import os from 'node:os';
4import { parseRef, encodeFragment } from './refs.js';
5import { parseOptions, resolveRef } from './resolve.js';
6import { highlightLines, langForFence } from './highlight.js';
7import { rowMatches } from './rows.js';
8import { resolveStat } from './stats.js';
9
10const md = new MarkdownIt({ html: false, linkify: true, typographer: false });
11
12export function esc(s) {
13 return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
14}
15
16export function isRefInfo(info) {
17 const first = (info || '').trim().split(/\s+/)[0];
18 return first === 'show' || first === 'diff' || first === '-C';
19}
20
21function slugify(text, used) {
22 let base = text.toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-') || 'section';
23 let slug = base;
24 for (let i = 2; used.has(slug); i++) slug = `${base}-${i}`;
25 used.add(slug);
26 return slug;
27}
28
29// Walks open with a blockquote telling people who see the raw .md how to view it. The viewer
30// is already the answer, so it doesn't render that quote.
31const HINT_RE = /^📖\s*\**Code Walk/;
32
33function stripViewingHint(tokens) {
34 const i = tokens.findIndex((t) => t.type === 'blockquote_open' && t.level === 0);
35 if (i === -1) return false;
36 const inline = tokens[i + 2];
37 if (!inline || inline.type !== 'inline' || !HINT_RE.test(inline.content)) return false;
38 let j = i + 1;
39 while (tokens[j].type !== 'blockquote_close' || tokens[j].level !== 0) j++;
40 tokens.splice(i, j - i + 1);
41 return true;
42}
43
44/**
45 * Parse a walk body and resolve every reference block.
46 * Returns { tokens, refs: [{ info, line, result?, error? }], title, toc }.
47 */
48export async function prepareWalk(walk, { highlight = true } = {}) {
49 const tokens = md.parse(walk.body, {});
50 const hasHint = stripViewingHint(tokens);
51 const used = new Set();
52 const toc = [];
53 let title = walk.title;
54 const refs = [];
55 const jobs = [];
56
57 for (let i = 0; i < tokens.length; i++) {
58 const t = tokens[i];
59 if (t.type === 'heading_open') {
60 const inline = tokens[i + 1];
61 const text = inline.children.filter((c) => c.type === 'text' || c.type === 'code_inline').map((c) => c.content).join('');
62 const id = slugify(text, used);
63 t.attrSet('id', id);
64 const level = Number(t.tag.slice(1));
65 if (level === 1 && !title) title = text;
66 if (level === 2 || level === 3) toc.push({ level, text, id });
67 }
68 if (t.type !== 'fence') continue;
69 const line = walk.bodyLineOffset + (t.map ? t.map[0] : 0) + 1;
70 if (isRefInfo(t.info)) {
71 const entry = { info: t.info.trim(), line };
72 refs.push(entry);
73 t.meta = entry;
74 jobs.push((async () => {
75 try {
76 const ref = parseRef(entry.info);
77 if (ref.stat) {
78 entry.stat = await resolveStat(walk, ref, t.content);
79 entry.warnings = entry.stat.warnings;
80 } else {
81 const options = parseOptions(t.content);
82 entry.result = await resolveRef(walk, ref, { options, tokens: highlight });
83 }
84 } catch (e) {
85 entry.error = e.message;
86 }
87 })());
88 } else if (highlight && t.info.trim().split(/\s+/)[0] !== 'mermaid') {
89 jobs.push((async () => {
90 t.meta = { lines: await highlightLines(t.content.replace(/\n$/, ''), langForFence(t.info)) };
91 })());
92 }
93 }
94 await Promise.all(jobs);
95 return { tokens, refs, title, toc, hasHint };
96}
97
98md.renderer.rules.table_open = () => '<table class="cw-table">\n';
99
100md.renderer.rules.fence = (tokens, idx) => {
101 const t = tokens[idx];
102 const lang = t.info.trim().split(/\s+/)[0];
103 if (isRefInfo(t.info)) return renderRefBlock(t.meta);
104 if (lang === 'mermaid') return `<div class="cw-mermaid"><pre class="mermaid">${esc(t.content)}</pre></div>\n`;
105 const lines = t.meta?.lines;
106 const body = lines ? lines.map((l) => renderTokens(l)).join('\n') : esc(t.content);
107 return `<pre class="cw-pre"><code>${body}</code></pre>\n`;
108};
109
110// ---- reference blocks -------------------------------------------------------
111
112let renderCtx = { walk: null };
113
114export function renderWalkHtml(prepared, walk) {
115 renderCtx = { walk };
116 return md.renderer.render(prepared.tokens, md.options, {});
117}
118
119function renderRefBlock(entry) {
120 if (entry.stat && !entry.error) return renderStat(entry.stat);
121 if (entry.error || !entry.result) {
122 return `<div class="cw-error" role="alert"><div class="cw-error-title">Couldn’t resolve <code>${esc(entry.info)}</code></div><div class="cw-error-msg">${esc(entry.error || 'unknown error')}</div></div>\n`;
123 }
124 const { result } = entry;
125 let html = '';
126 if (result.kind === 'commit') html += renderCommitCard(result);
127 for (const s of result.sections) html += renderSection(result, s);
128 return `<div class="cw-block">${html}</div>\n`;
129}
130
131function renderCommitCard(result) {
132 const c = result.commit;
133 const date = c.date ? new Date(c.date) : null;
134 return `<div class="cw-commit">
135 <div class="cw-commit-subject">${esc(c.subject)}</div>
136 ${c.body ? `<div class="cw-commit-body">${esc(c.body)}</div>` : ''}
137 <div class="cw-commit-meta">${repoLabel(result)}<span class="cw-side commit" title="${esc(c.sha)}"><i></i><code>${esc(c.sha.slice(0, 7))}</code></span><span>${esc(c.author)}</span>${date ? `<time datetime="${esc(c.date)}">${esc(date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }))}</time>` : ''}</div>
138</div>`;
139}
140
141function repoLabel(result) {
142 const walk = renderCtx.walk;
143 if (!walk || (!walk.multiRepo && result.repo.isDefault)) return '';
144 return `<span class="cw-repo" title="${esc(result.repo.remote || result.repo.root)}">${esc(result.repo.name)}</span>`;
145}
146
147function sideBadge(side) {
148 if (side.type === 'index') return '<span class="cw-side index" title="The staged version (git index)"><i></i>STAGED</span>';
149 if (side.type === 'worktree') return '<span class="cw-side worktree" title="The file on disk, including unstaged changes"><i></i>WORKING TREE</span>';
150 const info = side.info || {};
151 const title = [side.sha, info.subject, [info.author, info.date && info.date.slice(0, 10)].filter(Boolean).join(' · ')].filter(Boolean).join('\n');
152 const showRev = side.rev && !side.sha.startsWith(side.rev.toLowerCase()) && side.rev !== '(root)';
153 const label = side.sha === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? 'empty' : side.short;
154 return `<span class="cw-side commit${side.mergeBase ? ' merge-base' : ''}" title="${esc(title)}"><i></i><code>${esc(label)}</code>${showRev ? `<span class="cw-rev">${esc(side.rev)}</span>` : ''}</span>`;
155}
156
157const STATUS = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: null, T: 'type changed' };
158
159function renderSection(result, s) {
160 const walk = renderCtx.walk;
161 let status = '';
162 if (s.mode === 'diff') {
163 const label = STATUS[s.status];
164 if (label) status += `<span class="cw-status ${label.replace(' ', '-')}">${label}${(s.status === 'R' || s.status === 'C') ? ` from <code>${esc(s.oldPath)}</code>` : ''}</span>`;
165 if (!s.binary) status += `<span class="cw-stat"><span class="add">+${s.added}</span><span class="del">−${s.removed}</span></span>`;
166 }
167 const sides = s.mode === 'diff'
168 ? `${sideBadge(s.sides[0])}<span class="cw-arrow" aria-label="to">→</span>${sideBadge(s.sides[1])}`
169 : sideBadge(s.sides[0]);
170 const link = s.linkTokens.join(' ');
171 const attrs = [
172 `data-link="${esc(link)}"`,
173 `data-frag="${esc(encodeFragment(s.linkTokens))}"`,
174 `data-mode="${s.mode}"`,
175 `data-repo="${esc(result.repo.root)}"`,
176 `data-repo-name="${esc(result.repo.name)}"`,
177 `data-path="${esc(s.path)}"`,
178 ].join(' ');
179
180 let body;
181 if (s.binary) body = '<div class="cw-note">Binary file not shown</div>';
182 else if (!s.rows.length) body = `<div class="cw-note">${s.mode === 'diff' ? (s.status === 'A' ? 'Empty file added' : s.status === 'D' ? 'Empty file deleted' : 'No content changes') : 'Empty file'}</div>`;
183 else if (s.mode === 'diff' && !s.hunks.length && !s.span) body = '<div class="cw-note">No content changes</div>';
184 else body = `<div class="cw-scroll"><table class="cw-code ${s.mode}">${renderRows(s)}</table></div>`;
185
186 return `<figure class="cw-snippet" ${attrs}>
187<figcaption class="cw-head">
188 <div class="cw-head-row cw-title">${repoLabel(result)}<span class="cw-path">${esc(s.path)}</span>${status}<button class="cw-link-btn" type="button" title="Copy link to this snippet (y) · shift-click: copy for a prompt (Y)" aria-label="Copy link">${LINK_ICON}</button></div>
189 <div class="cw-head-row cw-meta"><span class="cw-sides">${sides}</span><span class="cw-cmd"><code>${esc(s.displayCmd)}</code><button class="cw-copy-cmd" type="button" title="Copy command" aria-label="Copy command">${COPY_ICON}</button></span></div>
190</figcaption>
191${body}
192</figure>\n`;
193}
194
195const LINK_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M7.78 3.84a.75.75 0 0 1 0 1.06L5.9 6.78a2.25 2.25 0 0 0 3.18 3.18l.53-.53a.75.75 0 1 1 1.06 1.06l-.53.53a3.75 3.75 0 0 1-5.3-5.3l1.88-1.88a.75.75 0 0 1 1.06 0Zm.44 8.32a.75.75 0 0 1 0-1.06l1.88-1.88a2.25 2.25 0 0 0-3.18-3.18l-.53.53a.75.75 0 1 1-1.06-1.06l.53-.53a3.75 3.75 0 0 1 5.3 5.3l-1.88 1.88a.75.75 0 0 1-1.06 0Z"/></svg>';
196const COPY_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M5.75 1h6.5C13.22 1 14 1.78 14 2.75v6.5c0 .97-.78 1.75-1.75 1.75H11v1.25c0 .97-.78 1.75-1.75 1.75h-6.5C1.78 14 1 13.22 1 12.25v-6.5C1 4.78 1.78 4 2.75 4H4V2.75C4 1.78 4.78 1 5.75 1Zm0 1.5a.25.25 0 0 0-.25.25v6.5c0 .14.11.25.25.25h6.5c.14 0 .25-.11.25-.25v-6.5a.25.25 0 0 0-.25-.25Zm-3 3a.25.25 0 0 0-.25.25v6.5c0 .14.11.25.25.25h6.5c.14 0 .25-.11.25-.25V11H5.75C4.78 11 4 10.22 4 9.25V5.5Z"/></svg>';
197
198function visibleMask(s) {
199 const mask = new Uint8Array(s.rows.length);
200 if (s.span) mask.fill(1, s.span.start, s.span.end);
201 else if (s.mode === 'diff') for (const h of s.hunks) mask.fill(1, h.start, h.end);
202 else mask.fill(1);
203 return mask;
204}
205
206function renderRows(s) {
207 const mask = visibleMask(s);
208 const out = [];
209 let i = 0;
210 const cols = s.mode === 'diff' ? 4 : 2;
211 while (i < s.rows.length) {
212 const vis = mask[i];
213 let j = i;
214 while (j < s.rows.length && mask[j] === vis) j++;
215 const rows = [];
216 for (let k = i; k < j; k++) rows.push(renderRow(s, s.rows[k]));
217 if (vis) {
218 out.push(`<tbody>${rows.join('')}</tbody>`);
219 } else {
220 const n = j - i;
221 const where = i === 0 ? 'up' : j === s.rows.length ? 'down' : 'mid';
222 // A line selection hides everything around it, changed lines included.
223 const changed = s.mode === 'diff' ? s.rows.slice(i, j).filter((r) => r.t !== ' ').length : 0;
224 const label = changed ? `${n} line${n === 1 ? '' : 's'}, ${changed} changed` : `${n} ${s.mode === 'diff' ? 'unchanged ' : ''}line${n === 1 ? '' : 's'}`;
225 const bar = `<tr class="cw-expander"><td colspan="${cols}"><button type="button" aria-expanded="false"><span class="cw-more">${EXPAND_ICON}<span>${label}</span></span><span class="cw-less">${COLLAPSE_ICON}<span>Hide ${label}</span></span></button></td></tr>`;
226 // Once open, the bar stays at the edge next to the visible code: below the rows for a fold at
227 // the end of the file, above them otherwise.
228 out.push(`<tbody class="cw-fold ${where}">${where === 'down' ? rows.join('') + bar : bar + rows.join('')}</tbody>`);
229 }
230 i = j;
231 }
232 return out.join('');
233}
234
235const EXPAND_ICON = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="m8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"/></svg>';
236
237const COLLAPSE_ICON = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="M10.896 2H8.75V.75a.75.75 0 0 0-1.5 0V2H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0l2.896-2.896A.25.25 0 0 0 10.896 2ZM8.75 15.25a.75.75 0 0 1-1.5 0V14H5.104a.25.25 0 0 1-.177-.427l2.896-2.896a.25.25 0 0 1 .354 0l2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25Zm-6.5-6.5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"/></svg>';
238
239function renderRow(s, r) {
240 const hl = s.highlight && s.highlight.some((spec) => rowMatches(r, spec, s.mode)) ? ' hl' : '';
241 if (s.mode === 'file') {
242 const toks = s.newTokens?.[r.n - 1];
243 return `<tr class="r${hl}" data-l="${r.n}"><td class="ln" data-side="L">${r.n}</td><td class="code">${toks ? renderTokens(toks) : ''}</td></tr>`;
244 }
245 const cls = r.t === '+' ? 'add' : r.t === '-' ? 'del' : 'ctx';
246 const toks = r.t === '-' ? s.oldTokens?.[r.o - 1] : s.newTokens?.[r.n - 1];
247 const mk = r.t === ' ' ? '' : r.t === '+' ? '+' : '−';
248 return `<tr class="r ${cls}${hl}"${r.o ? ` data-l="${r.o}"` : ''}${r.n ? ` data-r="${r.n}"` : ''}><td class="ln" data-side="L">${r.o ?? ''}</td><td class="ln" data-side="R">${r.n ?? ''}</td><td class="mk">${mk}</td><td class="code">${toks ? renderTokens(toks, r.marks) : ''}</td></tr>`;
249}
250
251function renderTokens(tokens, marks) {
252 let html = '';
253 let pos = 0;
254 let mi = 0;
255 for (const tok of tokens) {
256 const text = tok.content;
257 const end = pos + text.length;
258 let cursor = pos;
259 // Split the token at word-diff mark boundaries.
260 while (cursor < end) {
261 while (marks && mi < marks.length && marks[mi][1] <= cursor) mi++;
262 const m = marks && mi < marks.length ? marks[mi] : null;
263 let segEnd;
264 let marked = false;
265 if (m && m[0] <= cursor) { segEnd = Math.min(end, m[1]); marked = true; }
266 else if (m && m[0] < end) segEnd = m[0];
267 else segEnd = end;
268 const seg = text.slice(cursor - pos, segEnd - pos);
269 const span = tok.style ? `<span style="${tok.style}">${esc(seg)}</span>` : esc(seg);
270 html += marked ? `<mark>${span}</mark>` : span;
271 cursor = segEnd;
272 }
273 pos = end;
274 }
275 return html;
276}
277
278// ---- page -------------------------------------------------------------------
279
280export function tildify(p) {
281 const home = os.homedir();
282 return p && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
283}
284
285export function pageHtml({ title, toc, content, walk, walkName, hasMermaid }) {
286 const tocHtml = toc.length
287 ? `<nav class="cw-toc" aria-label="Contents"><div class="cw-toc-inner"><a class="l1 home" href="#" data-id="">${esc(title || walkName)}</a>${toc.map((h) => `<a class="l${h.level}" href="#${esc(h.id)}" data-id="${esc(h.id)}">${esc(h.text)}</a>`).join('')}</div></nav>`
288 : '';
289 const cfg = { walk: walkName, walkPath: walk.walkPath, multiRepo: walk.multiRepo };
290 const bodyHasH1 = /^<h1[\s>]/.test(content.trimStart());
291 return `<!doctype html>
292<html lang="en">
293<head>
294<meta charset="utf-8">
295<meta name="viewport" content="width=device-width, initial-scale=1">
296<title>${esc(title || walkName)}</title>
297<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>đŸš¶</text></svg>">
298<link rel="stylesheet" href="/assets/app.css">
299</head>
300<body>
301<div class="cw-layout">
302${tocHtml}
303<main class="cw-main">
304<header class="cw-header">
305 ${title && !bodyHasH1 ? `<h1>${esc(title)}</h1>` : ''}
306 <div class="cw-walkmeta">${walk.walkPath ? `<code>${esc(tildify(walk.walkPath))}</code>` : ''}${walk.repos.map((r) => `<span class="cw-repo-meta" title="${esc(r.remote || r.path)}">${esc(r.name)}</span>`).join('')}</div>
307</header>
308<article class="cw-article">
309${content}
310</article>
311</main>
312</div>
313<div class="cw-toast" role="status" aria-live="polite"></div>
314<script>window.CODE_WALK = ${JSON.stringify(cfg).replace(/</g, '\\u003c')};</script>
315${hasMermaid ? '<script src="/assets/mermaid.min.js"></script>' : ''}
316<script src="/assets/app.js"></script>
317</body>
318</html>`;
319}
320
321const commentMd = new MarkdownIt({ html: false, linkify: true, breaks: true });
322export function renderCommentBody(text) {
323 return commentMd.render(String(text || ''));
324}
325
326// ---- --stat summaries -----------------------------------------------------------
327
328const STATUS_LETTER = { A: 'A', D: 'D', R: 'R', C: 'C', M: 'M', T: 'T' };
329const STATUS_WORD = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: 'modified', T: 'type changed' };
330
331/** GitHub-style five-square change bar. */
332function changeBar(added, removed) {
333 const total = added + removed;
334 let green = total ? Math.round((5 * added) / total) : 0;
335 let red = total ? 5 - green : 0;
336 if (added && !green) { green = 1; red = 4; }
337 if (removed && !red) { red = 1; green = 4; }
338 const cells = [];
339 for (let i = 0; i < 5; i++) cells.push(`<i class="${i < green ? 'add' : i < green + red ? 'del' : ''}"></i>`);
340 return `<span class="cw-bar" aria-hidden="true">${cells.join('')}</span>`;
341}
342
343function counts(t) {
344 return `<span class="cw-stat"><span class="add">+${t.added}</span><span class="del">−${t.removed}</span></span>`;
345}
346
347const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
348
349function renderStat(stat) {
350 const [oldSide, newSide] = stat.sides;
351 const summary = [
352 plural(stat.totals.count, 'file'),
353 stat.commits != null ? plural(stat.commits, 'commit') : null,
354 ].filter(Boolean).join(' · ');
355 const title = stat.commit ? `<span class="cw-sum-subject">${esc(stat.commit.subject)}</span>` : '';
356
357 const fileRow = (file) => {
358 const rename = (file.status === 'R' || file.status === 'C') && file.oldPath !== file.path
359 ? `<span class="cw-sum-rename">from <code>${esc(file.oldPath)}</code></span>` : '';
360 return `<tr class="cw-sum-file" data-path="${esc(file.path)}">
361 <td class="cw-sum-status"><span class="s-${esc(file.status)}" title="${esc(STATUS_WORD[file.status] || file.status)}">${esc(STATUS_LETTER[file.status] || file.status)}</span></td>
362 <td class="cw-sum-name"><span class="cw-sum-path">${esc(file.path)}</span>${rename}${file.note ? `<div class="cw-sum-note">${esc(file.note)}</div>` : ''}</td>
363 <td class="cw-sum-counts">${file.binary ? '<span class="cw-sum-binary">binary</span>' : counts(file)}</td>
364 <td class="cw-sum-barcell">${file.binary ? '' : changeBar(file.added, file.removed)}</td>
365 </tr>`;
366 };
367
368 let body = '';
369 for (const g of stat.groups) {
370 if (stat.grouped) {
371 body += `<tbody class="cw-sum-group${g.implicit ? ' implicit' : ''}"><tr class="cw-sum-grouphead">
372 <td colspan="2"><span class="cw-sum-groupname">${esc(g.name)}</span>${g.note ? `<span class="cw-sum-groupnote">${esc(g.note)}</span>` : ''}${g.implicit ? '<span class="cw-sum-groupnote">not matched by any group</span>' : ''}<span class="cw-sum-groupcount">${plural(g.count, 'file')}</span></td>
373 <td class="cw-sum-counts">${counts(g)}</td>
374 <td class="cw-sum-barcell">${changeBar(g.added, g.removed)}</td>
375 </tr>${g.files.map(fileRow).join('')}</tbody>`;
376 } else {
377 body += `<tbody>${g.files.map(fileRow).join('')}</tbody>`;
378 }
379 }
380 const footer = stat.grouped && stat.groups.length > 1
381 ? `<tfoot><tr class="cw-sum-total"><td colspan="2">Total <span class="cw-sum-groupcount">${plural(stat.totals.count, 'file')}</span></td><td class="cw-sum-counts">${counts(stat.totals)}</td><td class="cw-sum-barcell">${changeBar(stat.totals.added, stat.totals.removed)}</td></tr></tfoot>`
382 : '';
383 const warnings = stat.warnings.length
384 ? `<div class="cw-sum-warnings">${stat.warnings.map((w) => `<div>⚠ ${esc(w)}</div>`).join('')}</div>` : '';
385 const empty = stat.files.length ? '' : '<div class="cw-note">No changes</div>';
386 const repoTag = renderCtx.walk && (renderCtx.walk.multiRepo || !stat.repo.isDefault)
387 ? `<span class="cw-repo" title="${esc(stat.repo.remote || stat.repo.root)}">${esc(stat.repo.name)}</span>` : '';
388
389 return `<div class="cw-block"><section class="cw-summary" data-repo="${esc(stat.repo.root)}">
390<header class="cw-head">
391 <div class="cw-head-row cw-title">${repoTag}<span class="cw-sum-headline">${summary}</span>${counts(stat.totals)}${changeBar(stat.totals.added, stat.totals.removed)}${title}</div>
392 <div class="cw-head-row cw-meta"><span class="cw-sides">${sideBadge(oldSide)}<span class="cw-arrow" aria-label="to">→</span>${sideBadge(newSide)}</span><span class="cw-cmd"><code>${esc(stat.displayCmd)}</code><button class="cw-copy-cmd" type="button" title="Copy command" aria-label="Copy command">${COPY_ICON}</button></span></div>
393</header>
394${empty}${stat.files.length ? `<div class="cw-scroll"><table class="cw-sum">${body}${footer}</table></div>` : ''}${warnings}
395</section></div>\n`;
396}

Because the code comes from git, a walk can't drift from the code it describes. Branch names are pinned to commits in links, and a walk about a branch still renders after the branch moves.

Each reference is resolved in parallel while the Markdown is parsed. Errors don't break the page: a block that can't be resolved shows the error in its place, and code-walk check reports it with a line number.

src/render.js
5c288d0git show 5c288d0:src/render.js
1// Markdown → HTML with git-referenced snippets.
2import MarkdownIt from 'markdown-it';
3import os from 'node:os';
4import { parseRef, encodeFragment } from './refs.js';
5import { parseOptions, resolveRef } from './resolve.js';
6import { highlightLines, langForFence } from './highlight.js';
7import { rowMatches } from './rows.js';
8import { resolveStat } from './stats.js';
9
10const md = new MarkdownIt({ html: false, linkify: true, typographer: false });
11
12export function esc(s) {
13 return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
14}
15
16export function isRefInfo(info) {
17 const first = (info || '').trim().split(/\s+/)[0];
18 return first === 'show' || first === 'diff' || first === '-C';
19}
20
21function slugify(text, used) {
22 let base = text.toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-') || 'section';
23 let slug = base;
24 for (let i = 2; used.has(slug); i++) slug = `${base}-${i}`;
25 used.add(slug);
26 return slug;
27}
28
29// Walks open with a blockquote telling people who see the raw .md how to view it. The viewer
30// is already the answer, so it doesn't render that quote.
31const HINT_RE = /^📖\s*\**Code Walk/;
32
33function stripViewingHint(tokens) {
34 const i = tokens.findIndex((t) => t.type === 'blockquote_open' && t.level === 0);
35 if (i === -1) return false;
36 const inline = tokens[i + 2];
37 if (!inline || inline.type !== 'inline' || !HINT_RE.test(inline.content)) return false;
38 let j = i + 1;
39 while (tokens[j].type !== 'blockquote_close' || tokens[j].level !== 0) j++;
40 tokens.splice(i, j - i + 1);
41 return true;
42}
43
44/**
45 * Parse a walk body and resolve every reference block.
46 * Returns { tokens, refs: [{ info, line, result?, error? }], title, toc }.
47 */
48export async function prepareWalk(walk, { highlight = true } = {}) {
49 const tokens = md.parse(walk.body, {});
50 const hasHint = stripViewingHint(tokens);
51 const used = new Set();
52 const toc = [];
53 let title = walk.title;
54 const refs = [];
55 const jobs = [];
56
57 for (let i = 0; i < tokens.length; i++) {
58 const t = tokens[i];
59 if (t.type === 'heading_open') {
60 const inline = tokens[i + 1];
61 const text = inline.children.filter((c) => c.type === 'text' || c.type === 'code_inline').map((c) => c.content).join('');
62 const id = slugify(text, used);
63 t.attrSet('id', id);
64 const level = Number(t.tag.slice(1));
65 if (level === 1 && !title) title = text;
66 if (level === 2 || level === 3) toc.push({ level, text, id });
67 }
68 if (t.type !== 'fence') continue;
69 const line = walk.bodyLineOffset + (t.map ? t.map[0] : 0) + 1;
70 if (isRefInfo(t.info)) {
71 const entry = { info: t.info.trim(), line };
72 refs.push(entry);
73 t.meta = entry;
74 jobs.push((async () => {
75 try {
76 const ref = parseRef(entry.info);
77 if (ref.stat) {
78 entry.stat = await resolveStat(walk, ref, t.content);
79 entry.warnings = entry.stat.warnings;
80 } else {
81 const options = parseOptions(t.content);
82 entry.result = await resolveRef(walk, ref, { options, tokens: highlight });
83 }
84 } catch (e) {
85 entry.error = e.message;
86 }
87 })());
88 } else if (highlight && t.info.trim().split(/\s+/)[0] !== 'mermaid') {
89 jobs.push((async () => {
90 t.meta = { lines: await highlightLines(t.content.replace(/\n$/, ''), langForFence(t.info)) };
91 })());
92 }
93 }
94 await Promise.all(jobs);
95 return { tokens, refs, title, toc, hasHint };
96}
97
98md.renderer.rules.table_open = () => '<table class="cw-table">\n';
99
100md.renderer.rules.fence = (tokens, idx) => {
101 const t = tokens[idx];
102 const lang = t.info.trim().split(/\s+/)[0];
103 if (isRefInfo(t.info)) return renderRefBlock(t.meta);
104 if (lang === 'mermaid') return `<div class="cw-mermaid"><pre class="mermaid">${esc(t.content)}</pre></div>\n`;
105 const lines = t.meta?.lines;
106 const body = lines ? lines.map((l) => renderTokens(l)).join('\n') : esc(t.content);
107 return `<pre class="cw-pre"><code>${body}</code></pre>\n`;
108};
109
110// ---- reference blocks -------------------------------------------------------
111
112let renderCtx = { walk: null };
113
114export function renderWalkHtml(prepared, walk) {
115 renderCtx = { walk };
116 return md.renderer.render(prepared.tokens, md.options, {});
117}
118
119function renderRefBlock(entry) {
120 if (entry.stat && !entry.error) return renderStat(entry.stat);
121 if (entry.error || !entry.result) {
122 return `<div class="cw-error" role="alert"><div class="cw-error-title">Couldn’t resolve <code>${esc(entry.info)}</code></div><div class="cw-error-msg">${esc(entry.error || 'unknown error')}</div></div>\n`;
123 }
124 const { result } = entry;
125 let html = '';
126 if (result.kind === 'commit') html += renderCommitCard(result);
127 for (const s of result.sections) html += renderSection(result, s);
128 return `<div class="cw-block">${html}</div>\n`;
129}
130
131function renderCommitCard(result) {
132 const c = result.commit;
133 const date = c.date ? new Date(c.date) : null;
134 return `<div class="cw-commit">
135 <div class="cw-commit-subject">${esc(c.subject)}</div>
136 ${c.body ? `<div class="cw-commit-body">${esc(c.body)}</div>` : ''}
137 <div class="cw-commit-meta">${repoLabel(result)}<span class="cw-side commit" title="${esc(c.sha)}"><i></i><code>${esc(c.sha.slice(0, 7))}</code></span><span>${esc(c.author)}</span>${date ? `<time datetime="${esc(c.date)}">${esc(date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }))}</time>` : ''}</div>
138</div>`;
139}
140
141function repoLabel(result) {
142 const walk = renderCtx.walk;
143 if (!walk || (!walk.multiRepo && result.repo.isDefault)) return '';
144 return `<span class="cw-repo" title="${esc(result.repo.remote || result.repo.root)}">${esc(result.repo.name)}</span>`;
145}
146
147function sideBadge(side) {
148 if (side.type === 'index') return '<span class="cw-side index" title="The staged version (git index)"><i></i>STAGED</span>';
149 if (side.type === 'worktree') return '<span class="cw-side worktree" title="The file on disk, including unstaged changes"><i></i>WORKING TREE</span>';
150 const info = side.info || {};
151 const title = [side.sha, info.subject, [info.author, info.date && info.date.slice(0, 10)].filter(Boolean).join(' · ')].filter(Boolean).join('\n');
152 const showRev = side.rev && !side.sha.startsWith(side.rev.toLowerCase()) && side.rev !== '(root)';
153 const label = side.sha === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? 'empty' : side.short;
154 return `<span class="cw-side commit${side.mergeBase ? ' merge-base' : ''}" title="${esc(title)}"><i></i><code>${esc(label)}</code>${showRev ? `<span class="cw-rev">${esc(side.rev)}</span>` : ''}</span>`;
155}
156
157const STATUS = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: null, T: 'type changed' };
158
159function renderSection(result, s) {
160 const walk = renderCtx.walk;
161 let status = '';
162 if (s.mode === 'diff') {
163 const label = STATUS[s.status];
164 if (label) status += `<span class="cw-status ${label.replace(' ', '-')}">${label}${(s.status === 'R' || s.status === 'C') ? ` from <code>${esc(s.oldPath)}</code>` : ''}</span>`;
165 if (!s.binary) status += `<span class="cw-stat"><span class="add">+${s.added}</span><span class="del">−${s.removed}</span></span>`;
166 }
167 const sides = s.mode === 'diff'
168 ? `${sideBadge(s.sides[0])}<span class="cw-arrow" aria-label="to">→</span>${sideBadge(s.sides[1])}`
169 : sideBadge(s.sides[0]);
170 const link = s.linkTokens.join(' ');
171 const attrs = [
172 `data-link="${esc(link)}"`,
173 `data-frag="${esc(encodeFragment(s.linkTokens))}"`,
174 `data-mode="${s.mode}"`,
175 `data-repo="${esc(result.repo.root)}"`,
176 `data-repo-name="${esc(result.repo.name)}"`,
177 `data-path="${esc(s.path)}"`,
178 ].join(' ');
179
180 let body;
181 if (s.binary) body = '<div class="cw-note">Binary file not shown</div>';
182 else if (!s.rows.length) body = `<div class="cw-note">${s.mode === 'diff' ? (s.status === 'A' ? 'Empty file added' : s.status === 'D' ? 'Empty file deleted' : 'No content changes') : 'Empty file'}</div>`;
183 else if (s.mode === 'diff' && !s.hunks.length && !s.span) body = '<div class="cw-note">No content changes</div>';
184 else body = `<div class="cw-scroll"><table class="cw-code ${s.mode}">${renderRows(s)}</table></div>`;
185
186 return `<figure class="cw-snippet" ${attrs}>
187<figcaption class="cw-head">
188 <div class="cw-head-row cw-title">${repoLabel(result)}<span class="cw-path">${esc(s.path)}</span>${status}<button class="cw-link-btn" type="button" title="Copy link to this snippet (y) · shift-click: copy for a prompt (Y)" aria-label="Copy link">${LINK_ICON}</button></div>
189 <div class="cw-head-row cw-meta"><span class="cw-sides">${sides}</span><span class="cw-cmd"><code>${esc(s.displayCmd)}</code><button class="cw-copy-cmd" type="button" title="Copy command" aria-label="Copy command">${COPY_ICON}</button></span></div>
190</figcaption>
191${body}
192</figure>\n`;
193}
194
195const LINK_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M7.78 3.84a.75.75 0 0 1 0 1.06L5.9 6.78a2.25 2.25 0 0 0 3.18 3.18l.53-.53a.75.75 0 1 1 1.06 1.06l-.53.53a3.75 3.75 0 0 1-5.3-5.3l1.88-1.88a.75.75 0 0 1 1.06 0Zm.44 8.32a.75.75 0 0 1 0-1.06l1.88-1.88a2.25 2.25 0 0 0-3.18-3.18l-.53.53a.75.75 0 1 1-1.06-1.06l.53-.53a3.75 3.75 0 0 1 5.3 5.3l-1.88 1.88a.75.75 0 0 1-1.06 0Z"/></svg>';
196const COPY_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M5.75 1h6.5C13.22 1 14 1.78 14 2.75v6.5c0 .97-.78 1.75-1.75 1.75H11v1.25c0 .97-.78 1.75-1.75 1.75h-6.5C1.78 14 1 13.22 1 12.25v-6.5C1 4.78 1.78 4 2.75 4H4V2.75C4 1.78 4.78 1 5.75 1Zm0 1.5a.25.25 0 0 0-.25.25v6.5c0 .14.11.25.25.25h6.5c.14 0 .25-.11.25-.25v-6.5a.25.25 0 0 0-.25-.25Zm-3 3a.25.25 0 0 0-.25.25v6.5c0 .14.11.25.25.25h6.5c.14 0 .25-.11.25-.25V11H5.75C4.78 11 4 10.22 4 9.25V5.5Z"/></svg>';
197
198function visibleMask(s) {
199 const mask = new Uint8Array(s.rows.length);
200 if (s.span) mask.fill(1, s.span.start, s.span.end);
201 else if (s.mode === 'diff') for (const h of s.hunks) mask.fill(1, h.start, h.end);
202 else mask.fill(1);
203 return mask;
204}
205
206function renderRows(s) {
207 const mask = visibleMask(s);
208 const out = [];
209 let i = 0;
210 const cols = s.mode === 'diff' ? 4 : 2;
211 while (i < s.rows.length) {
212 const vis = mask[i];
213 let j = i;
214 while (j < s.rows.length && mask[j] === vis) j++;
215 const rows = [];
216 for (let k = i; k < j; k++) rows.push(renderRow(s, s.rows[k]));
217 if (vis) {
218 out.push(`<tbody>${rows.join('')}</tbody>`);
219 } else {
220 const n = j - i;
221 const where = i === 0 ? 'up' : j === s.rows.length ? 'down' : 'mid';
222 // A line selection hides everything around it, changed lines included.
223 const changed = s.mode === 'diff' ? s.rows.slice(i, j).filter((r) => r.t !== ' ').length : 0;
224 const label = changed ? `${n} line${n === 1 ? '' : 's'}, ${changed} changed` : `${n} ${s.mode === 'diff' ? 'unchanged ' : ''}line${n === 1 ? '' : 's'}`;
225 const bar = `<tr class="cw-expander"><td colspan="${cols}"><button type="button" aria-expanded="false"><span class="cw-more">${EXPAND_ICON}<span>${label}</span></span><span class="cw-less">${COLLAPSE_ICON}<span>Hide ${label}</span></span></button></td></tr>`;
226 // Once open, the bar stays at the edge next to the visible code: below the rows for a fold at
227 // the end of the file, above them otherwise.
228 out.push(`<tbody class="cw-fold ${where}">${where === 'down' ? rows.join('') + bar : bar + rows.join('')}</tbody>`);
229 }
230 i = j;
231 }
232 return out.join('');
233}
234
235const EXPAND_ICON = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="m8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"/></svg>';
236
237const COLLAPSE_ICON = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="M10.896 2H8.75V.75a.75.75 0 0 0-1.5 0V2H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0l2.896-2.896A.25.25 0 0 0 10.896 2ZM8.75 15.25a.75.75 0 0 1-1.5 0V14H5.104a.25.25 0 0 1-.177-.427l2.896-2.896a.25.25 0 0 1 .354 0l2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25Zm-6.5-6.5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"/></svg>';
238
239function renderRow(s, r) {
240 const hl = s.highlight && s.highlight.some((spec) => rowMatches(r, spec, s.mode)) ? ' hl' : '';
241 if (s.mode === 'file') {
242 const toks = s.newTokens?.[r.n - 1];
243 return `<tr class="r${hl}" data-l="${r.n}"><td class="ln" data-side="L">${r.n}</td><td class="code">${toks ? renderTokens(toks) : ''}</td></tr>`;
244 }
245 const cls = r.t === '+' ? 'add' : r.t === '-' ? 'del' : 'ctx';
246 const toks = r.t === '-' ? s.oldTokens?.[r.o - 1] : s.newTokens?.[r.n - 1];
247 const mk = r.t === ' ' ? '' : r.t === '+' ? '+' : '−';
248 return `<tr class="r ${cls}${hl}"${r.o ? ` data-l="${r.o}"` : ''}${r.n ? ` data-r="${r.n}"` : ''}><td class="ln" data-side="L">${r.o ?? ''}</td><td class="ln" data-side="R">${r.n ?? ''}</td><td class="mk">${mk}</td><td class="code">${toks ? renderTokens(toks, r.marks) : ''}</td></tr>`;
249}
250
251function renderTokens(tokens, marks) {
252 let html = '';
253 let pos = 0;
254 let mi = 0;
255 for (const tok of tokens) {
256 const text = tok.content;
257 const end = pos + text.length;
258 let cursor = pos;
259 // Split the token at word-diff mark boundaries.
260 while (cursor < end) {
261 while (marks && mi < marks.length && marks[mi][1] <= cursor) mi++;
262 const m = marks && mi < marks.length ? marks[mi] : null;
263 let segEnd;
264 let marked = false;
265 if (m && m[0] <= cursor) { segEnd = Math.min(end, m[1]); marked = true; }
266 else if (m && m[0] < end) segEnd = m[0];
267 else segEnd = end;
268 const seg = text.slice(cursor - pos, segEnd - pos);
269 const span = tok.style ? `<span style="${tok.style}">${esc(seg)}</span>` : esc(seg);
270 html += marked ? `<mark>${span}</mark>` : span;
271 cursor = segEnd;
272 }
273 pos = end;
274 }
275 return html;
276}
277
278// ---- page -------------------------------------------------------------------
279
280export function tildify(p) {
281 const home = os.homedir();
282 return p && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
283}
284
285export function pageHtml({ title, toc, content, walk, walkName, hasMermaid }) {
286 const tocHtml = toc.length
287 ? `<nav class="cw-toc" aria-label="Contents"><div class="cw-toc-inner"><a class="l1 home" href="#" data-id="">${esc(title || walkName)}</a>${toc.map((h) => `<a class="l${h.level}" href="#${esc(h.id)}" data-id="${esc(h.id)}">${esc(h.text)}</a>`).join('')}</div></nav>`
288 : '';
289 const cfg = { walk: walkName, walkPath: walk.walkPath, multiRepo: walk.multiRepo };
290 const bodyHasH1 = /^<h1[\s>]/.test(content.trimStart());
291 return `<!doctype html>
292<html lang="en">
293<head>
294<meta charset="utf-8">
295<meta name="viewport" content="width=device-width, initial-scale=1">
296<title>${esc(title || walkName)}</title>
297<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>đŸš¶</text></svg>">
298<link rel="stylesheet" href="/assets/app.css">
299</head>
300<body>
301<div class="cw-layout">
302${tocHtml}
303<main class="cw-main">
304<header class="cw-header">
305 ${title && !bodyHasH1 ? `<h1>${esc(title)}</h1>` : ''}
306 <div class="cw-walkmeta">${walk.walkPath ? `<code>${esc(tildify(walk.walkPath))}</code>` : ''}${walk.repos.map((r) => `<span class="cw-repo-meta" title="${esc(r.remote || r.path)}">${esc(r.name)}</span>`).join('')}</div>
307</header>
308<article class="cw-article">
309${content}
310</article>
311</main>
312</div>
313<div class="cw-toast" role="status" aria-live="polite"></div>
314<script>window.CODE_WALK = ${JSON.stringify(cfg).replace(/</g, '\\u003c')};</script>
315${hasMermaid ? '<script src="/assets/mermaid.min.js"></script>' : ''}
316<script src="/assets/app.js"></script>
317</body>
318</html>`;
319}
320
321const commentMd = new MarkdownIt({ html: false, linkify: true, breaks: true });
322export function renderCommentBody(text) {
323 return commentMd.render(String(text || ''));
324}
325
326// ---- --stat summaries -----------------------------------------------------------
327
328const STATUS_LETTER = { A: 'A', D: 'D', R: 'R', C: 'C', M: 'M', T: 'T' };
329const STATUS_WORD = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: 'modified', T: 'type changed' };
330
331/** GitHub-style five-square change bar. */
332function changeBar(added, removed) {
333 const total = added + removed;
334 let green = total ? Math.round((5 * added) / total) : 0;
335 let red = total ? 5 - green : 0;
336 if (added && !green) { green = 1; red = 4; }
337 if (removed && !red) { red = 1; green = 4; }
338 const cells = [];
339 for (let i = 0; i < 5; i++) cells.push(`<i class="${i < green ? 'add' : i < green + red ? 'del' : ''}"></i>`);
340 return `<span class="cw-bar" aria-hidden="true">${cells.join('')}</span>`;
341}
342
343function counts(t) {
344 return `<span class="cw-stat"><span class="add">+${t.added}</span><span class="del">−${t.removed}</span></span>`;
345}
346
347const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
348
349function renderStat(stat) {
350 const [oldSide, newSide] = stat.sides;
351 const summary = [
352 plural(stat.totals.count, 'file'),
353 stat.commits != null ? plural(stat.commits, 'commit') : null,
354 ].filter(Boolean).join(' · ');
355 const title = stat.commit ? `<span class="cw-sum-subject">${esc(stat.commit.subject)}</span>` : '';
356
357 const fileRow = (file) => {
358 const rename = (file.status === 'R' || file.status === 'C') && file.oldPath !== file.path
359 ? `<span class="cw-sum-rename">from <code>${esc(file.oldPath)}</code></span>` : '';
360 return `<tr class="cw-sum-file" data-path="${esc(file.path)}">
361 <td class="cw-sum-status"><span class="s-${esc(file.status)}" title="${esc(STATUS_WORD[file.status] || file.status)}">${esc(STATUS_LETTER[file.status] || file.status)}</span></td>
362 <td class="cw-sum-name"><span class="cw-sum-path">${esc(file.path)}</span>${rename}${file.note ? `<div class="cw-sum-note">${esc(file.note)}</div>` : ''}</td>
363 <td class="cw-sum-counts">${file.binary ? '<span class="cw-sum-binary">binary</span>' : counts(file)}</td>
364 <td class="cw-sum-barcell">${file.binary ? '' : changeBar(file.added, file.removed)}</td>
365 </tr>`;
366 };
367
368 let body = '';
369 for (const g of stat.groups) {
370 if (stat.grouped) {
371 body += `<tbody class="cw-sum-group${g.implicit ? ' implicit' : ''}"><tr class="cw-sum-grouphead">
372 <td colspan="2"><span class="cw-sum-groupname">${esc(g.name)}</span>${g.note ? `<span class="cw-sum-groupnote">${esc(g.note)}</span>` : ''}${g.implicit ? '<span class="cw-sum-groupnote">not matched by any group</span>' : ''}<span class="cw-sum-groupcount">${plural(g.count, 'file')}</span></td>
373 <td class="cw-sum-counts">${counts(g)}</td>
374 <td class="cw-sum-barcell">${changeBar(g.added, g.removed)}</td>
375 </tr>${g.files.map(fileRow).join('')}</tbody>`;
376 } else {
377 body += `<tbody>${g.files.map(fileRow).join('')}</tbody>`;
378 }
379 }
380 const footer = stat.grouped && stat.groups.length > 1
381 ? `<tfoot><tr class="cw-sum-total"><td colspan="2">Total <span class="cw-sum-groupcount">${plural(stat.totals.count, 'file')}</span></td><td class="cw-sum-counts">${counts(stat.totals)}</td><td class="cw-sum-barcell">${changeBar(stat.totals.added, stat.totals.removed)}</td></tr></tfoot>`
382 : '';
383 const warnings = stat.warnings.length
384 ? `<div class="cw-sum-warnings">${stat.warnings.map((w) => `<div>⚠ ${esc(w)}</div>`).join('')}</div>` : '';
385 const empty = stat.files.length ? '' : '<div class="cw-note">No changes</div>';
386 const repoTag = renderCtx.walk && (renderCtx.walk.multiRepo || !stat.repo.isDefault)
387 ? `<span class="cw-repo" title="${esc(stat.repo.remote || stat.repo.root)}">${esc(stat.repo.name)}</span>` : '';
388
389 return `<div class="cw-block"><section class="cw-summary" data-repo="${esc(stat.repo.root)}">
390<header class="cw-head">
391 <div class="cw-head-row cw-title">${repoTag}<span class="cw-sum-headline">${summary}</span>${counts(stat.totals)}${changeBar(stat.totals.added, stat.totals.removed)}${title}</div>
392 <div class="cw-head-row cw-meta"><span class="cw-sides">${sideBadge(oldSide)}<span class="cw-arrow" aria-label="to">→</span>${sideBadge(newSide)}</span><span class="cw-cmd"><code>${esc(stat.displayCmd)}</code><button class="cw-copy-cmd" type="button" title="Copy command" aria-label="Copy command">${COPY_ICON}</button></span></div>
393</header>
394${empty}${stat.files.length ? `<div class="cw-scroll"><table class="cw-sum">${body}${footer}</table></div>` : ''}${warnings}
395</section></div>\n`;
396}

The collapsed bars above and below are the rest of the file. Click one to expand it, and click again to hide it.

Diffs

diff takes the same arguments as git diff: two commits, a range, main...feature for what a PR shows, --cached for staged changes, or nothing for your working tree. Here's the change that turned comment markers into a bar down the gutter:

web/app.css+7−5
a031379→5c288d0git diff a031379 5c288d0 -- web/app.css
11:root {
22 --bg: #ffffff;
33 --fg: #1f2328;
44 --fg-muted: #59636e;
55 --fg-subtle: #818b98;
66 --border: #d1d9e0;
77 --border-muted: #e4e8ec;
88 --surface: #f6f8fa;
99 --surface-2: #eff2f5;
1010 --link: #0969da;
1111 --accent: #8250df;
1212 --code-bg: #ffffff;
1313 --ln: #8c959f;
1414 --add-bg: #e6ffec;
1515 --add-ln-bg: #ccffd8;
1616 --add-mark: #abf2bc;
1717 --add-fg: #1a7f37;
1818 --del-bg: #ffebe9;
1919 --del-ln-bg: #ffd7d5;
2020 --del-mark: #ffc1bd;
2121 --del-fg: #cf222e;
2222 --hl-bg: #fff8c5;
2323 --hl-bar: #d4a72c;
2424 --sel-bg: rgba(130, 80, 223, 0.10);
2525 --sel-bar: #8250df;
2626 --fold-bg: #ddf4ff;
2727 --fold-fg: #0969da;
2828 --commit: #1f2328;
2929 --index: #9a6700;
3030 --index-bg: #fff8c5;
3131 --worktree: #0969da;
3232 --worktree-bg: #ddf4ff;
3333 --error-bg: #ffebe9;
3434 --error-border: #ff8182;
3535 --shadow: 0 1px 2px rgba(31, 35, 40, 0.06), 0 1px 3px rgba(31, 35, 40, 0.04);
3636 --radius: 10px;
3737 --prose-w: 760px;
3838 --block-w: min(1000px, calc(100vw - 32px));
3939 --font: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
4040 --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
4141 color-scheme: light;
4242}
4343
4444@media (prefers-color-scheme: dark) {
4545 :root {
4646 --bg: #0d1117;
4747 --fg: #e6edf3;
4848 --fg-muted: #9198a1;
4949 --fg-subtle: #6e7681;
5050 --border: #30363d;
5151 --border-muted: #21262d;
5252 --surface: #151b23;
5353 --surface-2: #1c232c;
5454 --link: #4493f8;
5555 --accent: #ab7df8;
5656 --code-bg: #0d1117;
5757 --ln: #6e7681;
5858 --add-bg: rgba(46, 160, 67, 0.15);
5959 --add-ln-bg: rgba(46, 160, 67, 0.25);
6060 --add-mark: rgba(46, 160, 67, 0.40);
6161 --add-fg: #3fb950;
6262 --del-bg: rgba(248, 81, 73, 0.10);
6363 --del-ln-bg: rgba(248, 81, 73, 0.22);
6464 --del-mark: rgba(248, 81, 73, 0.40);
6565 --del-fg: #f85149;
6666 --hl-bg: rgba(187, 128, 9, 0.18);
6767 --hl-bar: #d29922;
6868 --sel-bg: rgba(171, 125, 248, 0.14);
6969 --sel-bar: #ab7df8;
7070 --fold-bg: rgba(56, 139, 253, 0.12);
7171 --fold-fg: #4493f8;
7272 --commit: #e6edf3;
7373 --index: #d29922;
7474 --index-bg: rgba(187, 128, 9, 0.15);
7575 --worktree: #4493f8;
7676 --worktree-bg: rgba(56, 139, 253, 0.15);
7777 --error-bg: rgba(248, 81, 73, 0.10);
7878 --error-border: rgba(248, 81, 73, 0.5);
7979 --shadow: none;
8080 color-scheme: dark;
8181 }
8282 .cw-code .code span[style], .cw-pre span[style] { color: var(--shiki-dark) !important; }
8383}
8484
8585* { box-sizing: border-box; }
8686
8787html { -webkit-text-size-adjust: 100%; scroll-padding-top: 24px; }
8888
8989body {
9090 margin: 0;
9191 background: var(--bg);
9292 color: var(--fg);
9393 font: 16px/1.65 var(--font);
9494 -webkit-font-smoothing: antialiased;
9595 text-rendering: optimizeLegibility;
9696}
9797
9898/* ---- layout ---------------------------------------------------------------- */
9999
100100.cw-layout { display: block; }
101101.cw-main { max-width: var(--prose-w); margin: 0 auto; padding: 56px 16px 120px; }
102102
103103.cw-toc { display: none; }
104104
105105@media (min-width: 1280px) {
106106 :root { --block-w: min(1000px, calc(100vw - 600px)); }
107107 .cw-toc {
108108 display: block;
109109 position: fixed;
110110 top: 0;
111111 left: 0;
112112 width: 260px;
113113 height: 100vh;
114114 overflow-y: auto;
115115 padding: 64px 12px 32px 28px;
116116 }
117117 .cw-toc-inner { display: flex; flex-direction: column; gap: 1px; border-left: 1px solid var(--border-muted); }
118118 .cw-toc a {
119119 display: block;
120120 padding: 4px 10px;
121121 margin-left: -1px;
122122 border-left: 2px solid transparent;
123123 color: var(--fg-muted);
124124 text-decoration: none;
125125 font-size: 13px;
126126 line-height: 1.4;
127127 transition: color .15s, border-color .15s;
128128 }
129129 .cw-toc a.home { color: var(--fg); font-weight: 600; }
130130 .cw-toc a.l3 { padding-left: 22px; font-size: 12.5px; }
131131 .cw-toc a:hover { color: var(--fg); }
132132 .cw-toc a.active { color: var(--fg); border-left-color: var(--accent); font-weight: 500; }
133133}
134134
135135@media (min-width: 1500px) {
136136 :root { --block-w: min(1080px, calc(100vw - 560px)); }
137137}
138138
139139/* ---- prose ------------------------------------------------------------------ */
140140
141141.cw-header { margin-bottom: 36px; }
142142.cw-header h1, .cw-article h1 {
143143 font-size: 34px;
144144 line-height: 1.2;
145145 letter-spacing: -0.02em;
146146 font-weight: 700;
147147 margin: 0 0 12px;
148148 text-wrap: balance;
149149}
150150.cw-walkmeta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--fg-subtle); font-size: 13px; }
151151.cw-walkmeta code { font-family: var(--mono); font-size: 12px; }
152152.cw-repo-meta { font-family: var(--mono); font-size: 12px; padding: 1px 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--fg-muted); }
153153
154154.cw-article > :first-child { margin-top: 0; }
155155.cw-article h2 {
156156 font-size: 24px;
157157 line-height: 1.3;
158158 letter-spacing: -0.015em;
159159 margin: 56px 0 14px;
160160 font-weight: 650;
161161 text-wrap: balance;
162162}
163163.cw-article h3 { font-size: 19px; line-height: 1.35; margin: 36px 0 10px; font-weight: 600; }
164164.cw-article h4 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
165165.cw-article h2, .cw-article h3 { scroll-margin-top: 24px; }
166166.cw-article p, .cw-article ul, .cw-article ol, .cw-article blockquote, .cw-table { margin: 0 0 16px; }
167167.cw-article ul, .cw-article ol { padding-left: 1.5em; }
168168.cw-article li + li { margin-top: 4px; }
169169.cw-article a { color: var(--link); text-decoration: none; }
170170.cw-article a:hover { text-decoration: underline; }
171171.cw-article hr { border: 0; border-top: 1px solid var(--border-muted); margin: 40px 0; }
172172.cw-article blockquote { margin-left: 0; padding: 2px 16px; border-left: 3px solid var(--border); color: var(--fg-muted); }
173173.cw-article :not(pre) > code {
174174 font-family: var(--mono);
175175 font-size: 0.86em;
176176 padding: 0.15em 0.4em;
177177 background: var(--surface-2);
178178 border-radius: 6px;
179179}
180180table.cw-table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 15px; }
181181.cw-table th, .cw-table td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; }
182182.cw-table th { background: var(--surface); font-weight: 600; }
183183.cw-article img { max-width: 100%; }
184184
185185.cw-pre {
186186 font: 13px/1.6 var(--mono);
187187 background: var(--surface);
188188 border: 1px solid var(--border-muted);
189189 border-radius: var(--radius);
190190 padding: 14px 16px;
191191 overflow-x: auto;
192192 margin: 0 0 20px;
193193}
194194
195195.cw-index { list-style: none; padding: 0; }
196196.cw-index li { padding: 10px 0; border-bottom: 1px solid var(--border-muted); font-family: var(--mono); font-size: 14px; }
197197
198198/* ---- blocks that widen past the prose column ------------------------------- */
199199
200200.cw-block, .cw-mermaid, .cw-error {
201201 width: var(--block-w);
202202 margin: 24px 0 28px calc(50% - var(--block-w) / 2);
203203}
204204@media (min-width: 1280px) {
205205 .cw-block, .cw-mermaid, .cw-error {
206206 /* Keep wide blocks clear of the fixed TOC: shift right when needed. */
207207 margin-left: max(calc(50% - var(--block-w) / 2), calc(284px - (100vw - var(--prose-w)) / 2));
208208 }
209209}
210210
211211.cw-mermaid { overflow-x: auto; }
212212.cw-mermaid pre.mermaid { margin: 0; width: 100%; text-align: center; background: none; font-family: var(--font); color: transparent; }
213213.cw-mermaid pre.mermaid[data-processed] { color: inherit; }
214214.cw-mermaid svg { max-width: 100%; height: auto; }
215215
216216/* ---- snippet --------------------------------------------------------------- */
217217
218218.cw-snippet {
219219 margin: 0;
220220 border: 1px solid var(--border);
221221 border-radius: var(--radius);
222222 background: var(--code-bg);
223223 box-shadow: var(--shadow);
224224 overflow: hidden;
225225}
226226.cw-snippet + .cw-snippet { margin-top: 16px; }
227227
228228.cw-head { background: var(--surface); border-bottom: 1px solid var(--border-muted); padding: 9px 12px 8px 14px; }
229229.cw-head-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
230230.cw-title { font-size: 13px; }
231231.cw-meta { margin-top: 6px; justify-content: space-between; gap: 16px; flex-wrap: wrap; row-gap: 6px; }
232232
233233.cw-repo {
234234 font-family: var(--mono);
235235 font-size: 12px;
236236 color: var(--fg-muted);
237237 padding-right: 8px;
238238 border-right: 1px solid var(--border);
239239 white-space: nowrap;
240240}
241241.cw-path { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--fg); overflow-wrap: anywhere; }
242242
243243.cw-status { font-size: 11.5px; font-weight: 500; padding: 1px 7px; border-radius: 999px; background: var(--surface-2); color: var(--fg-muted); white-space: nowrap; }
244244.cw-status code { font-family: var(--mono); font-size: 11px; }
245245.cw-status.added { color: var(--add-fg); background: var(--add-bg); }
246246.cw-status.deleted { color: var(--del-fg); background: var(--del-bg); }
247247.cw-stat { font-family: var(--mono); font-size: 12px; display: inline-flex; gap: 6px; }
248248.cw-stat .add { color: var(--add-fg); }
249249.cw-stat .del { color: var(--del-fg); }
250250
251251.cw-link-btn, .cw-copy-cmd {
252252 appearance: none;
253253 border: 0;
254254 background: none;
255255 color: var(--fg-subtle);
256256 padding: 4px;
257257 border-radius: 6px;
258258 cursor: pointer;
259259 display: inline-flex;
260260 align-items: center;
261261 transition: color .15s, background .15s, opacity .15s;
262262}
263263.cw-link-btn { margin-left: auto; opacity: 0.55; }
264264.cw-snippet:hover .cw-link-btn, .cw-link-btn:focus-visible { opacity: 1; }
265265.cw-link-btn:hover, .cw-copy-cmd:hover { color: var(--fg); background: var(--surface-2); }
266266
267267.cw-sides { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; max-width: 100%; }
268268.cw-arrow { color: var(--fg-subtle); font-size: 13px; }
269269
270270.cw-side {
271271 display: inline-flex;
272272 align-items: center;
273273 gap: 6px;
274274 height: 22px;
275275 padding: 0 8px;
276276 border-radius: 6px;
277277 border: 1px solid var(--border);
278278 background: var(--bg);
279279 font-size: 11.5px;
280280 font-weight: 600;
281281 letter-spacing: 0.02em;
282282 white-space: nowrap;
283283 cursor: default;
284284 max-width: 100%;
285285 min-width: 0;
286286}
287287.cw-side code { font-family: var(--mono); font-size: 12px; font-weight: 500; letter-spacing: 0; }
288288.cw-side i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
289289.cw-side.commit { color: var(--commit); }
290290.cw-side.commit i { background: var(--commit); }
291291.cw-side.commit.merge-base i { background: transparent; border: 1.5px solid var(--commit); box-shadow: inset 0 0 0 1.5px var(--bg); background-color: var(--commit); }
292292.cw-rev { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 12px; font-weight: 400; color: var(--fg-muted); letter-spacing: 0; }
293293.cw-side.index { color: var(--index); background: var(--index-bg); border-color: transparent; }
294294.cw-side.index i { background: linear-gradient(90deg, var(--index) 50%, transparent 50%); border: 1.5px solid var(--index); }
295295.cw-side.worktree { color: var(--worktree); background: var(--worktree-bg); border-color: transparent; }
296296.cw-side.worktree i { border: 1.5px solid var(--worktree); }
297297
298298.cw-cmd { display: inline-flex; align-items: center; gap: 2px; min-width: 0; margin-left: auto; }
299299.cw-cmd code {
300300 font-family: var(--mono);
301301 font-size: 11.5px;
302302 color: var(--fg-subtle);
303303 white-space: nowrap;
304304 overflow: hidden;
305305 text-overflow: ellipsis;
306306 max-width: 60ch;
307307}
308308
309309.cw-note { padding: 14px 16px; color: var(--fg-muted); font-size: 14px; font-style: italic; }
310310
311311/* ---- code table ------------------------------------------------------------ */
312312
313313.cw-scroll { overflow-x: auto; }
314314.cw-code {
315315 border-collapse: collapse;
316316 width: 100%;
317317 font: 12.75px/20px var(--mono);
318318 font-variant-ligatures: none;
319319 tab-size: 4;
320320}
321321.cw-code td { padding: 0; vertical-align: top; }
322322.cw-code .ln {
323323 width: 1%;
324324 min-width: 44px;
325325 padding: 0 10px 0 12px;
326326 text-align: right;
327327 color: var(--ln);
328328 user-select: none;
329329 -webkit-user-select: none;
330330 cursor: pointer;
331331 white-space: nowrap;
332332}
333333.cw-code .ln:hover { color: var(--fg); }
334334.cw-code.diff .ln[data-side="R"] { padding-left: 4px; }
335335.cw-code .mk { width: 1%; padding: 0 4px 0 2px; user-select: none; -webkit-user-select: none; color: var(--fg-subtle); text-align: center; min-width: 14px; }
336336.cw-code .code { white-space: pre; padding: 0 16px 0 6px; }
337337.cw-code.file .code { padding-left: 10px; }
338338.cw-code tbody:first-child tr.r:first-child td, .cw-code tbody:first-child .cw-expander td { padding-top: 6px; }
339339.cw-code tbody:last-child tr.r:last-child td { padding-bottom: 6px; }
340340
341341.cw-code tr.add .code, .cw-code tr.add .mk { background: var(--add-bg); }
342342.cw-code tr.add .ln { background: var(--add-ln-bg); }
343343.cw-code tr.add .mk { color: var(--add-fg); }
344344.cw-code tr.del .code, .cw-code tr.del .mk { background: var(--del-bg); }
345345.cw-code tr.del .ln { background: var(--del-ln-bg); }
346346.cw-code tr.del .mk { color: var(--del-fg); }
347347.cw-code tr.add mark { background: var(--add-mark); color: inherit; border-radius: 3px; }
348348.cw-code tr.del mark { background: var(--del-mark); color: inherit; border-radius: 3px; }
349349.cw-code mark { padding: 1px 0; }
350350
351351.cw-code tr.hl .code { background-image: linear-gradient(var(--hl-bg), var(--hl-bg)); }
352352.cw-code tr.hl .ln:first-child { box-shadow: inset 3px 0 0 var(--hl-bar); }
353353
354354.cw-code tr.sel td { background-image: linear-gradient(var(--sel-bg), var(--sel-bg)); }
355355.cw-code tr.sel .ln:first-child { box-shadow: inset 3px 0 0 var(--sel-bar); }
356356.cw-code tr.sel .ln { color: var(--fg); background-color: color-mix(in srgb, var(--sel-bar) 22%, var(--code-bg)); }
357357.cw-code tr.flash td { animation: cw-flash 1.4s ease-out; }
358358@keyframes cw-flash {
359359 0%, 25% { background-color: color-mix(in srgb, var(--sel-bar) 26%, transparent); }
360360}
361361
362362.cw-fold:not(.open) tr.r { display: none; }
363363.cw-expander td { padding: 0 !important; }
364364.cw-expander button > span { display: flex; align-items: center; gap: 8px; }
365365.cw-fold:not(.open) .cw-less, .cw-fold.open .cw-more { display: none !important; }
366366.cw-expander button {
367367 appearance: none;
368368 display: flex;
369369 align-items: center;
370370 gap: 8px;
371371 width: 100%;
372372 border: 0;
373373 background: var(--fold-bg);
374374 color: var(--fold-fg);
375375 font: 12px/26px var(--font);
376376 padding: 0 14px;
377377 cursor: pointer;
378378 text-align: left;
379379 transition: filter .15s;
380380}
381381.cw-expander button:hover { filter: brightness(0.96); text-decoration: underline; }
382382@media (prefers-color-scheme: dark) { .cw-expander button:hover { filter: brightness(1.25); } }
383383
384384/* ---- commit card ----------------------------------------------------------- */
385385
386386.cw-commit {
387387 border: 1px solid var(--border);
388388 border-radius: var(--radius);
389389 padding: 14px 16px;
390390 margin-bottom: 16px;
391391 background: var(--surface);
392392}
393393.cw-commit-subject { font-weight: 600; font-size: 15px; line-height: 1.4; }
394394.cw-commit-body { white-space: pre-wrap; color: var(--fg-muted); font-size: 14px; margin-top: 6px; }
395395.cw-commit-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 10px; font-size: 13px; color: var(--fg-muted); }
396396
397397/* ---- errors, toast ----------------------------------------------------------- */
398398
399399.cw-error {
400400 border: 1px solid var(--error-border);
401401 background: var(--error-bg);
402402 border-radius: var(--radius);
403403 padding: 12px 16px;
404404 font-size: 14px;
405405}
406406.cw-error-title { font-weight: 600; margin-bottom: 4px; }
407407.cw-error-title code { font-family: var(--mono); font-size: 12.5px; }
408408.cw-error-msg { font-family: var(--mono); font-size: 12.5px; color: var(--del-fg); white-space: pre-wrap; }
409409
410410.cw-toast {
411411 position: fixed;
412412 left: 50%;
413413 top: 20px;
414414 z-index: 30;
415415 transform: translate(-50%, -12px);
416416 background: var(--fg);
417417 color: var(--bg);
418418 font-size: 13px;
419419 padding: 8px 14px;
420420 border-radius: 8px;
421421 opacity: 0;
422422 pointer-events: none;
423423 transition: opacity .18s, transform .18s;
424424 max-width: calc(100vw - 32px);
425425 white-space: nowrap;
426426 overflow: hidden;
427427 text-overflow: ellipsis;
428428}
429429.cw-toast.show { opacity: 1; transform: translate(-50%, 0); }
430430
431431/* ---- review: selection toolbar ------------------------------------------------- */
432432
433433.cw-selbar {
434434 position: fixed;
435435 left: 50%;
436436 bottom: 20px;
437437 z-index: 20;
438438 display: flex;
439439 align-items: center;
440440 gap: 2px;
441441 padding: 4px;
442442 max-width: calc(100vw - 32px);
443443 background: var(--bg);
444444 border: 1px solid var(--border);
445445 border-radius: 10px;
446446 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
447447 font-size: 13px;
448448 transform: translate(-50%, 12px);
449449 opacity: 0;
450450 pointer-events: none;
451451 transition: opacity .15s, transform .15s;
452452}
453453.cw-selbar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
454454.cw-selbar-range { padding: 0 10px 0 8px; color: var(--fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 12px; }
455455.cw-selbar-range code { color: var(--fg); }
456456.cw-selbar button {
457457 appearance: none;
458458 border: 0;
459459 background: none;
460460 color: var(--fg);
461461 font: 500 13px/1 var(--font);
462462 padding: 8px 10px;
463463 border-radius: 7px;
464464 cursor: pointer;
465465 display: inline-flex;
466466 align-items: center;
467467 gap: 6px;
468468 white-space: nowrap;
469469}
470470.cw-selbar button:hover { background: var(--surface-2); }
471471.cw-selbar button[data-act="clear"] { color: var(--fg-subtle); padding: 8px; }
472472.cw-selbar button[data-act="comment"] { color: var(--accent); }
473473kbd {
474474 font: 11px/1 var(--mono);
475475 padding: 2px 5px;
476476 border: 1px solid var(--border);
477477 border-bottom-width: 2px;
478478 border-radius: 4px;
479479 color: var(--fg-muted);
480480 background: var(--surface);
481481}
482482
483483/* ---- review: threads and composer ------------------------------------------------ */
484484
485+/* A solid bar down the gutter spanning the commented lines, capped at the range's ends. */
485486.cw-code tr.commented .ln:first-child::after {
486487 content: "";
487488 position: absolute;
488− left: 4px;
489− top: 7px;
490− width: 6px;
491− height: 6px;
492− border-radius: 50%;
489+ left: 5px;
490+ top: 0;
491+ bottom: 0;
492+ width: 3px;
493493 background: var(--accent);
494494}
495+.cw-code tr.commented.cm-start .ln:first-child::after { top: 3px; border-top-left-radius: 2px; border-top-right-radius: 2px; }
496+.cw-code tr.commented.cm-end .ln:first-child::after { bottom: 3px; border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; }
495497.cw-code tr.commented.resolved-only .ln:first-child::after { background: var(--border); }
496498.cw-code .ln:first-child { position: relative; }
497499
498500.cw-comment-row > td { padding: 0 !important; background: var(--surface); border-top: 1px solid var(--border-muted); border-bottom: 1px solid var(--border-muted); }
499501.cw-threads { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
500502.cw-composer-row .cw-composer { margin: 12px; }
501503
502504.cw-thread, .cw-composer {
503505 position: sticky;
504506 left: 12px;
505507 width: min(720px, calc(var(--block-w) - 26px));
506508 max-width: calc(100vw - 60px);
507509 font: 14px/1.55 var(--font);
508510 white-space: normal;
509511 background: var(--bg);
510512 border: 1px solid var(--border);
511513 border-radius: 8px;
512514}
513515.cw-thread { overflow: hidden; }
514516.cw-thread.flash { animation: cw-thread-flash 1.4s ease-out; }
515517@keyframes cw-thread-flash {
516518 0%, 30% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); }
517519 100% { box-shadow: 0 0 0 3px transparent; }
518520}
519521.cw-composer.inline { position: static; width: auto; max-width: none; margin: 4px 12px 12px 48px; }
520522.cw-composer.inline.reply { margin-top: 10px; }
521523.cw-composer { padding: 10px; }
522524.cw-composer-head { font-size: 13px; color: var(--fg-muted); margin: 0 0 8px 2px; }
523525.cw-composer textarea {
524526 display: block;
525527 width: 100%;
526528 min-height: 64px;
527529 resize: vertical;
528530 padding: 8px 10px;
529531 border: 1px solid var(--border);
530532 border-radius: 6px;
531533 background: var(--bg);
532534 color: var(--fg);
533535 font: 14px/1.5 var(--font);
534536}
535537.cw-composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
536538.cw-composer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 8px; }
537539.cw-hint { margin-right: auto; font-size: 12px; color: var(--fg-subtle); }
538540
539541.cw-btn {
540542 appearance: none;
541543 border: 1px solid var(--border);
542544 background: var(--surface);
543545 color: var(--fg);
544546 font: 500 13px/1 var(--font);
545547 padding: 7px 12px;
546548 border-radius: 6px;
547549 cursor: pointer;
548550}
549551.cw-btn:hover { background: var(--surface-2); }
550552.cw-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
551553.cw-btn.primary:hover { filter: brightness(1.08); }
552554.cw-btn:disabled { opacity: .6; cursor: default; }
553555.cw-btn.ghost { border-color: transparent; background: none; color: var(--fg-muted); padding: 6px 8px; }
554556.cw-btn.ghost:hover { color: var(--fg); background: var(--surface-2); }
555557
556558.cw-msg { display: flex; gap: 10px; padding: 12px 14px 4px; }
557559.cw-msg.reply { padding-top: 8px; }
558560.cw-msg + .cw-msg.reply { border-top: 1px solid var(--border-muted); margin-top: 6px; padding-top: 12px; }
559561.cw-avatar {
560562 flex: none;
561563 width: 24px;
562564 height: 24px;
563565 border-radius: 50%;
564566 display: grid;
565567 place-items: center;
566568 font-size: 12px;
567569 font-weight: 600;
568570 color: var(--fg-muted);
569571 background: var(--surface-2);
570572 border: 1px solid var(--border);
571573}
572574.cw-avatar.claude { color: #fff; background: #d97757; border-color: #d97757; font-size: 13px; }
573575.cw-msg-main { min-width: 0; flex: 1; }
574576.cw-msg-meta { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
575577.cw-msg-meta b { font-weight: 600; }
576578.cw-msg-meta time, .cw-edited { color: var(--fg-subtle); font-size: 12px; }
577579.cw-msg-menu { margin-left: auto; display: inline-flex; gap: 2px; opacity: 0; transition: opacity .15s; }
578580.cw-thread:hover .cw-msg-menu, .cw-msg-menu:focus-within { opacity: 1; }
579581.cw-msg-menu button { appearance: none; border: 0; background: none; color: var(--fg-subtle); font: 12px var(--font); padding: 2px 6px; border-radius: 4px; cursor: pointer; }
580582.cw-msg-menu button:hover { color: var(--fg); background: var(--surface-2); }
581583.cw-msg-body { margin-top: 2px; overflow-wrap: anywhere; }
582584.cw-msg-body > :first-child { margin-top: 0; }
583585.cw-msg-body > :last-child { margin-bottom: 0; }
584586.cw-msg-body p, .cw-msg-body ul, .cw-msg-body ol, .cw-msg-body pre { margin: 0 0 8px; }
585587.cw-msg-body ul, .cw-msg-body ol { padding-left: 1.3em; }
586588.cw-msg-body code { font-family: var(--mono); font-size: 0.86em; padding: 0.1em 0.35em; background: var(--surface-2); border-radius: 4px; }
587589.cw-msg-body pre { background: var(--surface); padding: 8px 10px; border-radius: 6px; overflow-x: auto; }
588590.cw-msg-body pre code { padding: 0; background: none; }
589591.cw-msg-body a { color: var(--link); }
590592
591593.cw-thread-actions { display: flex; gap: 2px; padding: 4px 10px 8px 44px; }
592594.cw-thread-link { margin-left: auto; }
593595
594596.cw-thread.resolved { border-style: dashed; }
595597.cw-thread-toggle {
596598 appearance: none;
597599 display: flex;
598600 align-items: center;
599601 gap: 6px;
600602 width: 100%;
601603 border: 0;
602604 background: none;
603605 padding: 9px 14px;
604606 font: 13px var(--font);
605607 color: var(--fg-muted);
606608 cursor: pointer;
607609 text-align: left;
608610}
609611.cw-thread:not(.collapsed) .cw-thread-toggle { border-bottom: 1px solid var(--border-muted); }
610612.cw-thread-toggle:hover { color: var(--fg); }
611613.cw-thread-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
612614.cw-thread-count { margin-left: auto; font-size: 12px; color: var(--fg-subtle); white-space: nowrap; }
613615.cw-check { color: var(--add-fg); font-weight: 700; }
614616
615617.cw-unplaced { margin-top: 64px; }
616618.cw-unplaced h2 { font-size: 18px !important; color: var(--fg-muted); }
617619.cw-unplaced .cw-thread { position: static; width: auto; max-width: none; margin-bottom: 12px; }
618620.cw-quote { border-bottom: 1px solid var(--border-muted); background: var(--surface); }
619621.cw-quote-head { padding: 8px 14px 0; font-size: 12px; color: var(--fg-muted); }
620622.cw-quote-head code { font-family: var(--mono); }
621623.cw-quote pre { margin: 0; padding: 6px 14px 10px; font: 12px/1.6 var(--mono); overflow-x: auto; }
622624.cw-quote pre .add { color: var(--add-fg); }
623625.cw-quote pre .del { color: var(--del-fg); }
624626
625627@media (max-width: 640px) {
626628 body { font-size: 15.5px; }
627629 .cw-main { padding-top: 32px; }
628630 .cw-header h1, .cw-article h1 { font-size: 27px; }
629631 .cw-article h2 { font-size: 21px; margin-top: 44px; }
630632 .cw-snippet { border-radius: 8px; }
631633 .cw-cmd code { max-width: 40ch; }
632634}
633635
634636@media (prefers-reduced-motion: reduce) {
635637 * { transition: none !important; animation: none !important; }
636638}
637639
638640/* Inline delete confirmation stays visible while pending. */
639641.cw-msg-menu.confirming { opacity: 1; align-items: baseline; }
640642.cw-confirm-text { font-size: 12px; color: var(--fg-muted); margin-right: 2px; }
641643.cw-msg-menu button.danger { color: var(--del-fg); font-weight: 600; }
642644.cw-msg-menu button.danger:hover { background: var(--del-bg); color: var(--del-fg); }
643645
644646/* Editing a message: the textarea takes the place of the text, aligned with it and sized to fit. */
645647.cw-composer.inline.edit { margin: -2px 0 4px -11px; padding: 0; border: 0; background: none; }
646648.cw-composer.inline.edit textarea { min-height: 0; padding: 5px 10px; resize: none; overflow: hidden; line-height: 1.55; }
647649.cw-composer textarea { overflow-y: hidden; }
648650.cw-composer.inline.edit .cw-composer-actions { margin-top: 6px; }
649651.cw-thread-actions.editing { align-items: center; }
650652.cw-thread-actions.editing .cw-hint { margin: 0 0 0 auto; padding-right: 4px; }
651653.cw-btn.ghost.save { color: var(--accent); font-weight: 600; }
652654.cw-btn.ghost.save:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
653655/* No Edit / Delete menus while a message in the thread is being edited. */
654656.cw-thread.editing .cw-msg-menu { display: none; }
655657
656658/* Edit conflict: the message changed after editing started. */
657659.cw-conflict {
658660 margin: 0 0 8px;
659661 padding: 10px 12px;
660662 border: 1px solid color-mix(in srgb, var(--hl-bar) 55%, transparent);
661663 background: var(--hl-bg);
662664 border-radius: 6px;
663665 font-size: 13px;
664666}
665667.cw-conflict-head { font-weight: 600; margin-bottom: 6px; }
666668.cw-conflict-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-muted); margin-bottom: 2px; }
667669.cw-conflict-current { white-space: pre-wrap; overflow-wrap: anywhere; padding: 6px 8px; background: var(--bg); border-radius: 4px; border: 1px solid var(--border-muted); }
668670.cw-conflict-actions { display: flex; gap: 2px; margin: 6px 0 0 -8px; }
669671
670672/* ---- --stat summaries ------------------------------------------------------------ */
671673
672674.cw-summary {
673675 border: 1px solid var(--border);
674676 border-radius: var(--radius);
675677 background: var(--code-bg);
676678 box-shadow: var(--shadow);
677679 overflow: hidden;
678680}
679681.cw-sum-headline { font-weight: 600; font-size: 13px; }
680682.cw-sum-subject { color: var(--fg-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
681683.cw-bar { display: inline-flex; gap: 1px; vertical-align: middle; }
682684.cw-bar i { width: 8px; height: 8px; border-radius: 1.5px; background: var(--border); }
683685.cw-bar i.add { background: var(--add-fg); }
684686.cw-bar i.del { background: var(--del-fg); }
685687
686688.cw-sum { width: 100%; border-collapse: collapse; font-size: 13px; }
687689.cw-sum td { padding: 5px 10px; vertical-align: top; border-top: 1px solid var(--border-muted); }
688690.cw-sum tbody:first-child tr:first-child td { border-top: 0; }
689691.cw-sum-status { width: 1%; padding-left: 14px !important; padding-right: 2px !important; }
690692.cw-sum-status span {
691693 display: inline-grid;
692694 place-items: center;
693695 width: 16px;
694696 height: 16px;
695697 border-radius: 4px;
696698 font: 600 10px/1 var(--mono);
697699 color: var(--fg-muted);
698700 background: var(--surface-2);
699701}
700702.cw-sum-status .s-A { color: var(--add-fg); background: var(--add-bg); }
701703.cw-sum-status .s-D { color: var(--del-fg); background: var(--del-bg); }
702704.cw-sum-status .s-R, .cw-sum-status .s-C { color: var(--worktree); background: var(--worktree-bg); }
703705.cw-sum-path { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
704706button.cw-sum-path {
705707 appearance: none; border: 0; background: none; padding: 0; color: var(--link); cursor: pointer; text-align: left;
706708}
707709button.cw-sum-path:hover { text-decoration: underline; }
708710.cw-sum-rename { margin-left: 8px; color: var(--fg-subtle); font-size: 12px; }
709711.cw-sum-rename code { font-family: var(--mono); }
710712.cw-sum-note { color: var(--fg-muted); font-size: 12.5px; margin-top: 1px; }
711713.cw-sum-counts { width: 1%; white-space: nowrap; text-align: right; }
712714.cw-sum-counts .cw-stat { justify-content: flex-end; }
713715.cw-sum-barcell { width: 1%; padding-right: 14px !important; white-space: nowrap; }
714716.cw-sum-binary { color: var(--fg-subtle); font-size: 12px; }
715717
716718.cw-sum-grouphead td { background: var(--surface); padding-top: 8px; padding-bottom: 8px; border-top: 1px solid var(--border); }
717719.cw-sum tbody:first-child .cw-sum-grouphead td { border-top: 0; }
718720.cw-sum-grouphead td:first-child { padding-left: 14px; }
719721.cw-sum-groupname { font-weight: 600; }
720722.cw-sum-groupnote { color: var(--fg-muted); margin-left: 8px; }
721723.cw-sum-groupcount { color: var(--fg-subtle); margin-left: 8px; font-size: 12px; }
722724.cw-sum-group.implicit .cw-sum-groupname { color: var(--fg-muted); font-style: italic; }
723725.cw-sum-total td { border-top: 1px solid var(--border); background: var(--surface); font-weight: 600; padding-top: 8px; padding-bottom: 8px; }
724726.cw-sum-total td:first-child { padding-left: 14px; }
725727.cw-sum-warnings { border-top: 1px solid var(--border-muted); padding: 8px 14px; font-size: 12.5px; color: var(--index); background: var(--index-bg); }
726728.cw-snippet.flash { animation: cw-thread-flash 1.4s ease-out; }

Pick part of a diff with hunk=N, or with line ranges on the old (L) or new (R) side. Here's the hunk where a walk's repo: learned to accept a git URL:

src/walk.js+29−8
64b543f→9728c9egit diff 64b543f 9728c9e -- src/walk.js
11// Loading a walk file: frontmatter, repo configuration.
22import { readFile } from 'node:fs/promises';
33import { existsSync, statSync } from 'node:fs';
44import os from 'node:os';
55import path from 'node:path';
66import YAML from 'yaml';
77import { repoRoot, GitError } from './git.js';
8+import { cacheDirFor, ensureRemote, isRemoteUrl, remoteName } from './remote.js';
89
910export function splitFrontmatter(src) {
1011 const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src);
1112 if (!m) return { data: {}, body: src, bodyLineOffset: 0 };
1213 let data = {};
1314 try {
1415 data = YAML.parse(m[1]) || {};
1516 } catch (e) {
1617 throw new Error(`invalid frontmatter: ${e.message}`);
1718 }
1819 return { data, body: src.slice(m[0].length), bodyLineOffset: m[0].split('\n').length - 1 };
1920}
2021
2122function expandHome(p) {
2223 return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p;
2324}
2425
26+/** A repo entry for a `repo:` / `repos:` value, which is a path (relative to the walk) or a git URL. */
27+function repoEntry(name, value, dir) {
28+ const configured = String(value);
29+ if (isRemoteUrl(configured)) return { name, configured, path: cacheDirFor(configured), remote: configured };
30+ return { name, configured, path: path.resolve(dir, expandHome(configured)) };
31+}
32+
2533/**
2634 * A walk context knows how to find repos.
2735 * { walkPath, dir, title, data, body, bodyLineOffset, repos: [{name, path}], defaultRepo, multiRepo }
2836 */
2937export async function loadWalk(walkPath, { fallbackDir = process.cwd() } = {}) {
3038 const src = await readFile(walkPath, 'utf8');
3139 return walkFromSource(src, walkPath, { fallbackDir });
3240}
3341
3442export async function walkFromSource(src, walkPath, { fallbackDir = process.cwd() } = {}) {
3543 const { data, body, bodyLineOffset } = splitFrontmatter(src);
3644 const dir = walkPath ? path.dirname(path.resolve(walkPath)) : fallbackDir;
3745 const repos = [];
38− const declared = data.repos ?? (data.repo ? { [path.basename(path.resolve(dir, expandHome(String(data.repo))))]: data.repo } : null);
46+ const single = data.repo ? String(data.repo) : null;
47+ const declared = data.repos ?? (single ? { [isRemoteUrl(single) ? remoteName(single) : path.basename(path.resolve(dir, expandHome(single)))]: single } : null);
3948 if (declared && typeof declared === 'object') {
40− for (const [name, p] of Object.entries(declared)) {
41− repos.push({ name, configured: String(p), path: path.resolve(dir, expandHome(String(p))) });
42− }
49+ for (const [name, p] of Object.entries(declared)) repos.push(repoEntry(name, p, dir));
4350 }
4451 if (!repos.length) {
4552 const root = (await repoRoot(dir)) || (await repoRoot(fallbackDir));
4653 if (root) repos.push({ name: path.basename(root), configured: root, path: root });
4754 }
4855 return {
4956 walkPath: walkPath ? path.resolve(walkPath) : null,
5057 dir,
5158 title: data.title ? String(data.title) : null,
5259 data,
5360 body,
5461 bodyLineOffset,
5562 repos,
5663 multiRepo: repos.length > 1,
5764 rootCache: new Map(),
5865 };
5966}
6067
6168/** Resolve a `-C` value (name or path) to {name, root}. */
6269export async function resolveRepo(walk, name) {
6370 let entry;
6471 if (name == null) {
6572 entry = walk.repos[0];
6673 if (!entry) throw new GitError('no git repository found (add `repo:` or `repos:` to the frontmatter)');
6774 } else {
6875 entry = walk.repos.find((r) => r.name === name);
69− if (!entry) {
76+ if (!entry && isRemoteUrl(name)) {
77+ entry = { ...repoEntry(name, name, walk.dir), adhoc: true };
78+ } else if (!entry) {
7079 const p = path.resolve(walk.dir, expandHome(name));
7180 if (!existsSync(p) || !statSync(p).isDirectory()) {
7281 const known = walk.repos.map((r) => r.name).join(', ');
7382 throw new GitError(`unknown repo "${name}"${known ? ` (known: ${known})` : ''}`);
7483 }
7584 entry = { name, configured: name, path: p, adhoc: true };
7685 }
7786 }
78− if (!walk.rootCache.has(entry.path)) walk.rootCache.set(entry.path, repoRoot(entry.path));
87+ if (!walk.rootCache.has(entry.path)) {
88+ walk.rootCache.set(entry.path, (async () => {
89+ if (entry.remote) await ensureRemote(entry.remote, entry.path);
90+ return repoRoot(entry.path);
91+ })());
92+ }
7993 const root = await walk.rootCache.get(entry.path);
80− if (!root) throw new GitError(`not a git repository: ${entry.path}`);
81− return { name: entry.name, root, isDefault: entry === walk.repos[0], configured: entry.configured };
94+ if (!root) throw new GitError(`not a git repository: ${entry.remote || entry.path}`);
95+ return { name: entry.name, root, isDefault: entry === walk.repos[0], configured: entry.configured, remote: entry.remote || null };
96+}
97+
98+/** The `-C <dir>` a runnable git command needs for this repo, or null when the cwd is right. */
99+export function repoDirArg(repo) {
100+ // A remote repo lives in the clone cache, so a runnable command has to say where.
101+ if (repo.remote) return repo.root.startsWith(os.homedir() + path.sep) ? `~${repo.root.slice(os.homedir().length)}` : repo.root;
102+ return repo.isDefault ? null : repo.configured;
82103}

Commits

show <commit> renders the commit message, author and date above its diff. Add -- <path> to narrow it to some files:

Draw comment anchors as a solid vertical bar (#8)
Replace the per-line dot with a continuous gutter bar spanning the commented range, with rounded caps on the first and last lines, so it's clear which lines a comment refers to. Closes #7. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
5c288d0Jeff Baumes
web/app.js+3−1
a0313795c288d0^→5c288d0git show 5c288d0 -- web/app.js
11(() => {
22 const CFG = window.CODE_WALK || {};
33 const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
44 const SEL_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/;
55
66 // ---- toast ------------------------------------------------------------------
77 const toastEl = document.querySelector('.cw-toast');
88 let toastTimer;
99 function toast(msg) {
1010 if (!toastEl) return;
1111 toastEl.textContent = msg;
1212 toastEl.classList.add('show');
1313 clearTimeout(toastTimer);
1414 toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800);
1515 }
1616
1717 async function copy(text, msg) {
1818 try {
1919 await navigator.clipboard.writeText(text);
2020 } catch {
2121 const ta = document.createElement('textarea');
2222 ta.value = text;
2323 ta.style.position = 'fixed';
2424 ta.style.opacity = '0';
2525 document.body.appendChild(ta);
2626 ta.select();
2727 document.execCommand('copy');
2828 ta.remove();
2929 }
3030 toast(msg);
3131 }
3232
3333 // ---- live reload (keeps scroll position) -------------------------------------
3434 const SCROLL_KEY = `cw-scroll:${location.pathname}`;
3535 try {
3636 const y = sessionStorage.getItem(SCROLL_KEY);
3737 if (y !== null) {
3838 sessionStorage.removeItem(SCROLL_KEY);
3939 history.scrollRestoration = 'manual';
4040 window.__cwRestored = Number(y);
4141 }
4242 } catch { /* storage unavailable */ }
4343
4444 if (window.EventSource) {
4545 const es = new EventSource('/events');
4646 es.onmessage = (ev) => {
4747 let data = {};
4848 try { data = JSON.parse(ev.data); } catch { return; }
4949 if (data.kind === 'comments') {
5050 if (data.walk === CFG.walk) loadComments();
5151 return;
5252 }
5353 if (!CFG.walk || data.walk === CFG.walk) {
5454 try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch { /* ignore */ }
5555 location.reload();
5656 }
5757 };
5858 }
5959
6060 // ---- mermaid -------------------------------------------------------------------
6161 let layoutReady = Promise.resolve();
6262 if (window.mermaid) {
6363 const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
6464 const css = getComputedStyle(document.documentElement);
6565 window.mermaid.initialize({
6666 startOnLoad: false,
6767 securityLevel: 'strict',
6868 theme: dark ? 'dark' : 'neutral',
6969 fontFamily: css.getPropertyValue('--font').trim(),
7070 });
7171 layoutReady = window.mermaid.run({ querySelector: 'pre.mermaid' }).catch((e) => console.error(e));
7272 }
7373
7474 // ---- folds ------------------------------------------------------------------------
7575 function openFold(node) {
7676 const fold = node.closest('.cw-fold');
7777 if (!fold) return;
7878 fold.classList.add('open');
7979 fold.querySelector('.cw-expander button')?.setAttribute('aria-expanded', 'true');
8080 }
8181 document.addEventListener('click', (e) => {
8282 const btn = e.target.closest('.cw-expander button');
8383 if (!btn) return;
8484 const open = btn.closest('.cw-fold').classList.toggle('open');
8585 btn.setAttribute('aria-expanded', String(open));
8686 });
8787
8888 // ---- line selection ---------------------------------------------------------------
8989 let current = null; // { fig, anchor: {idx, side}, focus: {idx, side} }
9090
9191 const rowsOf = (fig) => $$('tr.r', fig);
9292
9393 function lineOf(row, side, mode) {
9494 const v = row.dataset[side === 'L' ? 'l' : 'r'];
9595 return v === undefined ? null : Number(v);
9696 }
9797
9898 function pickSide(row, preferred, mode) {
9999 if (mode === 'file') return 'L';
100100 if (lineOf(row, preferred) !== null) return preferred;
101101 return preferred === 'L' ? 'R' : 'L';
102102 }
103103
104104 function rangeFor(fig, a, b) {
105105 const rows = rowsOf(fig);
106106 const mode = fig.dataset.mode;
107107 let [s, e] = a.idx <= b.idx ? [a, b] : [b, a];
108108 const sr = rows[s.idx];
109109 const er = rows[e.idx];
110110 if (mode === 'file') {
111111 const x = lineOf(sr, 'L');
112112 const y = lineOf(er, 'L');
113113 return x === y ? `L${x}` : `L${x}-${y}`;
114114 }
115115 const has = (row, side) => lineOf(row, side) !== null;
116116 let ss = s.side;
117117 let es = e.side;
118118 const valid = (p, q) => has(sr, p) && has(er, q) && !(p === 'R' && q === 'L');
119119 if (!valid(ss, es)) {
120120 const options = [['L', 'L'], ['R', 'R'], ['L', 'R']];
121121 const found = options.find(([p, q]) => valid(p, q));
122122 if (found) [ss, es] = found;
123123 else {
124124 // Added lines followed by deleted lines: fall back to the new side.
125125 ss = 'R';
126126 es = 'R';
127127 let j = e.idx;
128128 while (j > s.idx && !has(rows[j], 'R')) j--;
129129 e = { idx: j };
130130 }
131131 }
132132 const x = lineOf(sr, ss);
133133 const y = lineOf(rows[e.idx], es);
134134 if (ss === es) return x === y ? `${ss}${x}` : `${ss}${x}-${y}`;
135135 return `${ss}${x}-${es}${y}`;
136136 }
137137
138138 /** Mirrors selectionSpan() on the server. Returns {start, end} (end exclusive) or null. */
139139 function spanFor(fig, range) {
140140 const m = SEL_RE.exec(range);
141141 if (!m || m[5]) return null;
142142 const rows = rowsOf(fig);
143143 const mode = fig.dataset.mode;
144144 const s = { side: mode === 'file' ? 'L' : m[1], line: Number(m[2]) };
145145 const e = m[4] ? { side: mode === 'file' ? 'L' : (m[3] || m[1]), line: Number(m[4]) } : { ...s };
146146 let start = -1;
147147 let end = -1;
148148 if (s.side === e.side) {
149149 rows.forEach((r, i) => {
150150 const ln = lineOf(r, s.side);
151151 if (ln !== null && ln >= s.line && ln <= e.line) {
152152 if (start === -1) start = i;
153153 end = i;
154154 }
155155 });
156156 } else {
157157 start = rows.findIndex((r) => lineOf(r, s.side) === s.line);
158158 for (let i = rows.length - 1; i >= 0; i--) if (lineOf(rows[i], e.side) === e.line) { end = i; break; }
159159 }
160160 if (start === -1 || end === -1 || end < start) return null;
161161 return { start, end: end + 1 };
162162 }
163163
164164 function clearSelection() {
165165 $$('tr.sel').forEach((r) => r.classList.remove('sel'));
166166 current = null;
167167 onSelectionChange();
168168 }
169169
170170 function paint(fig, span, { reveal = false, flash = false } = {}) {
171171 $$('tr.sel').forEach((r) => r.classList.remove('sel'));
172172 const rows = rowsOf(fig);
173173 for (let i = span.start; i < span.end; i++) {
174174 const r = rows[i];
175175 r.classList.add('sel');
176176 openFold(r);
177177 if (flash) {
178178 r.classList.remove('flash');
179179 void r.offsetWidth;
180180 r.classList.add('flash');
181181 }
182182 }
183183 onSelectionChange();
184184 if (reveal) {
185185 const first = rows[span.start];
186186 const rect = first.getBoundingClientRect();
187187 const target = window.scrollY + rect.top - Math.max(80, window.innerHeight * 0.3);
188188 window.scrollTo({ top: Math.max(0, target), behavior: 'instant' in window ? 'instant' : 'auto' });
189189 }
190190 }
191191
192192 function snippetUrl(fig, range) {
193193 const frag = fig.dataset.frag + (range ? `&${range}` : '');
194194 return `${location.origin}${location.pathname}#${frag}`;
195195 }
196196
197197 function updateUrl(fig, range) {
198198 history.replaceState(null, '', `#${fig.dataset.frag}${range ? `&${range}` : ''}`);
199199 }
200200
201201 document.addEventListener('click', (e) => {
202202 const cell = e.target.closest('.cw-code td.ln');
203203 if (!cell) return;
204204 const fig = cell.closest('.cw-snippet');
205205 const row = cell.closest('tr.r');
206206 const rows = rowsOf(fig);
207207 const idx = rows.indexOf(row);
208208 const side = pickSide(row, cell.dataset.side, fig.dataset.mode);
209209 if (!current || current.fig !== fig || !e.shiftKey) {
210210 if (current && current.fig === fig && current.anchor.idx === idx && current.focus.idx === idx && !e.shiftKey) {
211211 clearSelection();
212212 history.replaceState(null, '', location.pathname);
213213 return;
214214 }
215215 current = { fig, anchor: { idx, side }, focus: { idx, side } };
216216 } else {
217217 current.focus = { idx, side };
218218 window.getSelection()?.removeAllRanges();
219219 }
220220 const range = rangeFor(fig, current.anchor, current.focus);
221221 const span = spanFor(fig, range);
222222 if (span) paint(fig, span);
223223 current.range = range;
224224 updateUrl(fig, range);
225225 onSelectionChange();
226226 });
227227
228228 function decodeFragment(hash) {
229229 let frag = hash.replace(/^#/, '');
230230 let range = null;
231231 const amp = frag.lastIndexOf('&');
232232 if (amp !== -1 && SEL_RE.test(frag.slice(amp + 1))) {
233233 range = frag.slice(amp + 1);
234234 frag = frag.slice(0, amp);
235235 }
236236 let tokens;
237237 try {
238238 tokens = frag.split('+').filter(Boolean).map((t) => decodeURIComponent(t));
239239 } catch {
240240 return null;
241241 }
242242 return { tokens, range };
243243 }
244244
245245 function findSnippet(link, range) {
246246 const strip = (s) => s.replace(/^-C \S+ /, '');
247247 const figs = $$('.cw-snippet');
248248 let matches = figs.filter((f) => f.dataset.link === link);
249249 if (!matches.length) matches = figs.filter((f) => strip(f.dataset.link) === strip(link));
250250 // Prefer a snippet that already shows the lines without expanding folds.
251251 return matches.find((f) => {
252252 const span = range && spanFor(f, range);
253253 return span && rowsOf(f).slice(span.start, span.end).every((r) => !r.closest('.cw-fold'));
254254 }) || matches.find((f) => range && spanFor(f, range)) || (range ? null : matches[0]) || null;
255255 }
256256
257257 function applyHash({ flash = true } = {}) {
258258 const cm = /^#comment-([0-9a-f]+)$/.exec(location.hash);
259259 if (cm) return commentsReady.then(() => focusThread(cm[1]));
260260 const hash = location.hash;
261261 if (!hash || document.getElementById(decodeURIComponent(hash.slice(1)))) return;
262262 const d = decodeFragment(hash);
263263 if (!d || !d.tokens.length || !['show', 'diff', '-C'].includes(d.tokens[0])) return;
264264 const fig = findSnippet(d.tokens.join(' '), d.range);
265265 if (!fig) {
266266 toast('That snippet isn’t in this walk');
267267 return;
268268 }
269269 if (!d.range) {
270270 fig.scrollIntoView({ block: 'start' });
271271 return;
272272 }
273273 const span = spanFor(fig, d.range);
274274 if (!span) {
275275 toast(`Lines ${d.range} aren’t shown in that snippet`);
276276 fig.scrollIntoView({ block: 'start' });
277277 return;
278278 }
279279 const rows = rowsOf(fig);
280280 const sideOf = (r, pref) => pickSide(r, pref, fig.dataset.mode);
281281 const m = SEL_RE.exec(d.range);
282282 current = {
283283 fig,
284284 anchor: { idx: span.start, side: sideOf(rows[span.start], m[1]) },
285285 focus: { idx: span.end - 1, side: sideOf(rows[span.end - 1], m[3] || m[1]) },
286286 range: d.range,
287287 };
288288 paint(fig, span, { reveal: window.__cwRestored === undefined, flash });
289289 }
290290
291291 window.addEventListener('hashchange', () => applyHash());
292292 // Wait for diagrams to take their final size so scroll positions are stable.
293293 layoutReady.then(() => {
294294 if (window.__cwRestored !== undefined) window.scrollTo(0, window.__cwRestored);
295295 applyHash({ flash: window.__cwRestored === undefined });
296296 });
297297
298298 // ---- copying ----------------------------------------------------------------------
299299 function sectionTitle(fig) {
300300 let title = null;
301301 for (const h of $$('.cw-article h1, .cw-article h2, .cw-article h3')) {
302302 if (h.compareDocumentPosition(fig) & Node.DOCUMENT_POSITION_FOLLOWING) title = h.textContent.trim();
303303 else break;
304304 }
305305 return title;
306306 }
307307
308308 function promptText(fig, range) {
309309 const tokens = fig.dataset.link.replace(/^-C \S+ /, '');
310310 const repo = fig.dataset.repo;
311311 const lines = range ? ` lines ${range}` : '';
312312 const where = [
313313 CFG.walkPath ? `walk: ${CFG.walkPath}` : null,
314314 sectionTitle(fig) ? `section "${sectionTitle(fig)}"` : null,
315315 ].filter(Boolean).join(', ');
316316 let ref;
317317 if (/^show [^:\s]+$/.test(tokens)) ref = `${fig.dataset.path}${lines} (working tree of ${repo})`;
318318 else ref = `\`git -C ${repo} ${tokens}\`${lines}${range && fig.dataset.mode === 'diff' ? ' (L = old side, R = new side)' : ''}`;
319319 return `${ref}${where ? `\n${where}` : ''}\n${snippetUrl(fig, range)}`;
320320 }
321321
322322 function copyLink(fig, forPrompt) {
323323 const range = current && current.fig === fig ? current.range : null;
324324 if (forPrompt) copy(promptText(fig, range), 'Copied reference for a prompt');
325325 else copy(snippetUrl(fig, range), range ? `Copied link to ${range}` : 'Copied link to snippet');
326326 }
327327
328328 document.addEventListener('click', (e) => {
329329 const linkBtn = e.target.closest('.cw-link-btn');
330330 if (linkBtn) return copyLink(linkBtn.closest('.cw-snippet'), e.shiftKey);
331331 const cmdBtn = e.target.closest('.cw-copy-cmd');
332332 if (cmdBtn) {
333333 const code = cmdBtn.parentElement.querySelector('code');
334334 copy(code.textContent, 'Copied command');
335335 }
336336 });
337337
338338 // ---- keyboard ---------------------------------------------------------------------
339339 const headings = () => $$('.cw-article h2, .cw-article h3');
340340
341341 document.addEventListener('keydown', (e) => {
342342 if (e.metaKey || e.ctrlKey || e.altKey) return;
343343 if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable]')) return;
344344 if (e.key === 'c') {
345345 e.preventDefault();
346346 openComposer();
347347 } else if (e.key === 'n' || e.key === 'p') {
348348 jumpToThread(e.key === 'n' ? 1 : -1);
349349 } else if (e.key === 'y' || e.key === 'Y') {
350350 if (!current) return toast('Click a line number to select lines first');
351351 copyLink(current.fig, e.key === 'Y');
352352 } else if (e.key === 'j' || e.key === 'k') {
353353 const hs = headings();
354354 if (!hs.length) return;
355355 const y = 30;
356356 let target;
357357 if (e.key === 'j') target = hs.find((h) => h.getBoundingClientRect().top > y + 1);
358358 else target = [...hs].reverse().find((h) => h.getBoundingClientRect().top < y - 1);
359359 if (target) target.scrollIntoView({ block: 'start' });
360360 else if (e.key === 'k') window.scrollTo({ top: 0 });
361361 } else if (e.key === 'Escape' && current) {
362362 clearSelection();
363363 history.replaceState(null, '', location.pathname);
364364 }
365365 });
366366
367367 // ---- --stat summaries: files shown elsewhere in the walk link to their snippet --------
368368 for (const row of $$('.cw-sum-file')) {
369369 const repo = row.closest('.cw-summary').dataset.repo;
370370 const target = $$('.cw-snippet').find((f) => f.dataset.repo === repo && f.dataset.path === row.dataset.path);
371371 if (!target) continue;
372372 const label = row.querySelector('.cw-sum-path');
373373 const btn = document.createElement('button');
374374 btn.type = 'button';
375375 btn.className = 'cw-sum-path';
376376 btn.title = 'Jump to this file in the walk';
377377 btn.textContent = label.textContent;
378378 btn.addEventListener('click', () => {
379379 target.scrollIntoView({ block: 'start' });
380380 window.scrollBy(0, -24);
381381 target.classList.remove('flash');
382382 void target.offsetWidth;
383383 target.classList.add('flash');
384384 });
385385 label.replaceWith(btn);
386386 }
387387
388388 // ---- review comments -----------------------------------------------------------------
389389 // Threads are stored by the server in <walk>.comments.json and anchored to a snippet's
390390 // link + line range, with a copy of the lines, so Claude can act on them via the CLI.
391391 // editBase: the text each open editor started from. Saves send it so the server can refuse to
392392 // overwrite a message that changed meanwhile (e.g. Claude edited it from the CLI).
393393 const review = { me: 'You', comments: [], drafts: new Map(), replying: new Set(), editing: new Set(), editBase: new Map(), loaded: false };
394394 let resolveCommentsReady;
395395 const commentsReady = new Promise((r) => { resolveCommentsReady = r; });
396396
397397 const el = (tag, cls, html) => {
398398 const n = document.createElement(tag);
399399 if (cls) n.className = cls;
400400 if (html !== undefined) n.innerHTML = html;
401401 return n;
402402 };
403403 const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
404404
405405 function ago(iso) {
406406 const s = (Date.now() - new Date(iso).getTime()) / 1000;
407407 if (s < 45) return 'just now';
408408 if (s < 3600) return `${Math.round(s / 60)}m ago`;
409409 if (s < 86400) return `${Math.round(s / 3600)}h ago`;
410410 if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`;
411411 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
412412 }
413413
414414 async function api(action, { id, body } = {}) {
415415 const q = new URLSearchParams({ walk: CFG.walk });
416416 if (id) q.set('id', id);
417417 const res = await fetch(`/api/comments${action}?${q}`, {
418418 method: 'POST',
419419 headers: { 'content-type': 'application/json' },
420420 body: JSON.stringify(body || {}),
421421 });
422422 const data = await res.json().catch(() => ({}));
423423 if (!res.ok) throw Object.assign(new Error(data.error || `request failed (${res.status})`), { status: res.status, data });
424424 return data;
425425 }
426426
427427 async function loadComments() {
428428 if (!CFG.walk) return;
429429 try {
430430 const res = await fetch(`/api/comments?walk=${encodeURIComponent(CFG.walk)}`);
431431 if (!res.ok) return;
432432 const data = await res.json();
433433 review.me = data.me || 'You';
434434 review.comments = data.comments || [];
435435 review.loaded = true;
436436 renderThreads();
437437 } catch (e) {
438438 console.error(e);
439439 } finally {
440440 resolveCommentsReady();
441441 }
442442 }
443443
444444 function anchorFor(fig, range) {
445445 const span = spanFor(fig, range);
446446 const rows = rowsOf(fig).slice(span.start, span.end);
447447 const file = fig.dataset.mode === 'file';
448448 return {
449449 link: fig.dataset.link,
450450 range,
451451 path: fig.dataset.path,
452452 mode: fig.dataset.mode,
453453 repo: fig.dataset.repo,
454454 repoName: fig.dataset.repoName,
455455 command: fig.dataset.link,
456456 section: sectionTitle(fig),
457457 lines: rows.map((r) => ({
458458 o: !file && r.dataset.l ? Number(r.dataset.l) : null,
459459 n: file ? Number(r.dataset.l) : r.dataset.r ? Number(r.dataset.r) : null,
460460 t: r.classList.contains('add') ? '+' : r.classList.contains('del') ? '-' : ' ',
461461 text: r.querySelector('.code').textContent,
462462 })),
463463 };
464464 }
465465
466466 // -- selection toolbar --
467467 const selbar = el('div', 'cw-selbar');
468468 selbar.setAttribute('role', 'toolbar');
469469 document.body.appendChild(selbar);
470470
471471 function onSelectionChange() {
472472 if (!current || !current.range || !CFG.walk) {
473473 selbar.classList.remove('show');
474474 document.body.classList.remove('has-selbar');
475475 return;
476476 }
477477 selbar.innerHTML = `<span class="cw-selbar-range"><code>${escHtml(current.fig.dataset.path.split('/').pop())}</code> ${escHtml(current.range)}</span>`
478478 + '<button type="button" data-act="comment">Comment <kbd>c</kbd></button>'
479479 + '<button type="button" data-act="link">Copy link <kbd>y</kbd></button>'
480480 + '<button type="button" data-act="clear" aria-label="Clear selection" title="Clear selection (Esc)">✕</button>';
481481 selbar.classList.add('show');
482482 document.body.classList.add('has-selbar');
483483 }
484484
485485 selbar.addEventListener('click', (e) => {
486486 const act = e.target.closest('button')?.dataset.act;
487487 if (act === 'comment') openComposer();
488488 else if (act === 'link' && current) copyLink(current.fig, e.shiftKey);
489489 else if (act === 'clear') {
490490 clearSelection();
491491 history.replaceState(null, '', location.pathname);
492492 }
493493 });
494494
495495 // -- composer for a new comment --
496496 let composer = null; // { fig, range, row }
497497
498498 function closeComposer() {
499499 composer?.row.remove();
500500 composer = null;
501501 }
502502
503503 function openComposer() {
504504 if (!current || !current.range) return toast('Select lines first: click a line number (shift-click for a range)');
505505 const { fig, range } = current;
506506 const span = spanFor(fig, range);
507507 if (!span) return;
508508 if (composer && composer.fig === fig && composer.range === range) {
509509 composer.row.querySelector('textarea').focus();
510510 return;
511511 }
512512 closeComposer();
513513 const rows = rowsOf(fig);
514514 const cols = rows[0].children.length;
515515 const row = el('tr', 'cw-comment-row cw-composer-row');
516516 const td = el('td');
517517 td.colSpan = cols;
518518 td.innerHTML = `<div class="cw-composer">
519519 <div class="cw-composer-head">Comment on <b>${escHtml(range)}</b></div>
520520 <textarea rows="3" placeholder="Leave a review comment
 (Markdown supported)"></textarea>
521521 <div class="cw-composer-actions"><span class="cw-hint">⌘↩ to save · Esc to cancel</span><button type="button" class="cw-btn" data-act="cancel">Cancel</button><button type="button" class="cw-btn primary" data-act="save">Comment</button></div>
522522 </div>`;
523523 row.appendChild(td);
524524 rows[span.end - 1].after(row);
525525 composer = { fig, range, row };
526526 const ta = td.querySelector('textarea');
527527 ta.value = review.drafts.get('new') || '';
528528 ta.focus();
529529 }
530530
531531 async function saveComposer() {
532532 if (!composer) return;
533533 const ta = composer.row.querySelector('textarea');
534534 const body = ta.value.trim();
535535 if (!body) return ta.focus();
536536 const btn = composer.row.querySelector('[data-act="save"]');
537537 btn.disabled = true;
538538 try {
539539 const { comment } = await api('', { body: { anchor: anchorFor(composer.fig, composer.range), body } });
540540 review.drafts.delete('new');
541541 closeComposer();
542542 const first = !review.comments.length;
543543 review.comments.push(comment);
544544 renderThreads();
545545 toast(first ? 'Comment saved. When you’re done, ask Claude to “address my code walk comments”' : 'Comment saved');
546546 } catch (e) {
547547 btn.disabled = false;
548548 toast(e.message);
549549 }
550550 }
551551
552552 // -- thread rendering --
553553 function avatar(name) {
554554 const claude = /^claude$/i.test(name);
555555 return `<span class="cw-avatar${claude ? ' claude' : ''}" aria-hidden="true">${claude ? '✳' : escHtml((name || '?').trim().charAt(0).toUpperCase())}</span>`;
556556 }
557557
558558 // A message key is the thread id, or "<thread id>/<reply id>" for a reply.
559559 function messageHtml(m, { id, isRoot }) {
560560 const key = isRoot ? id : `${id}/${m.id}`;
561561 const k = escHtml(key);
562562 const editing = review.editing.has(key);
563563 const what = !isRoot ? 'reply' : m.replies?.length ? 'thread' : 'comment';
564564 const menu = review.confirmingDelete === key
565565 ? `<span class="cw-msg-menu confirming"><span class="cw-confirm-text">Delete ${what}?</span><button type="button" class="danger" data-act="confirm-delete" data-key="${k}">Delete</button><button type="button" data-act="cancel-delete">Cancel</button></span>`
566566 : `<span class="cw-msg-menu"><button type="button" data-act="edit" data-key="${k}">Edit</button><button type="button" data-act="delete" data-key="${k}"${what === 'thread' ? ' title="Deletes the whole thread"' : ''}>Delete</button></span>`;
567567 return `<div class="cw-msg${isRoot ? '' : ' reply'}" data-key="${k}">
568568 ${avatar(m.author)}
569569 <div class="cw-msg-main">
570570 <div class="cw-msg-meta"><b>${escHtml(m.author)}</b><time datetime="${escHtml(m.created)}" title="${escHtml(new Date(m.created).toLocaleString())}">${ago(m.created)}</time>${m.edited ? '<span class="cw-edited">edited</span>' : ''}${menu}</div>
571571 ${editing
572572 ? `<div class="cw-composer inline edit">${conflictHtml(key, m)}<textarea rows="1" data-draft="edit:${k}"></textarea></div>`
573573 : `<div class="cw-msg-body">${m.bodyHtml}</div>`}
574574 </div>
575575 </div>`;
576576 }
577577
578578 /** Shown in an open editor when the message changed after editing started. */
579579 function conflictHtml(key, m) {
580580 const base = review.editBase.get(key);
581581 if (base === undefined || base === m.body) return '';
582582 const k = escHtml(key);
583583 return `<div class="cw-conflict" role="alert">
584584 <div class="cw-conflict-head">This message changed while you were editing. Your text below hasn’t been saved.</div>
585585 <div class="cw-conflict-label">Latest version</div>
586586 <div class="cw-conflict-current">${escHtml(m.body)}</div>
587587 <div class="cw-conflict-actions"><button type="button" class="cw-btn ghost save" data-act="keep-mine" data-key="${k}">Save mine anyway</button><button type="button" class="cw-btn ghost" data-act="take-latest" data-key="${k}">Edit the latest instead</button></div>
588588 </div>`;
589589 }
590590
591591 function threadEl(c, { quote = false } = {}) {
592592 const resolved = c.status === 'resolved';
593593 const expanded = !resolved || review.expanded?.has(c.id);
594594 const node = el('div', `cw-thread${resolved ? ' resolved' : ''}${expanded ? '' : ' collapsed'}`);
595595 node.id = `comment-${c.id}`;
596596 node.dataset.id = c.id;
597597 const a = c.anchor || {};
598598 const summary = (c.body || '').split('\n')[0].slice(0, 120);
599599 let html = '';
600600 if (resolved) {
601601 html += `<button type="button" class="cw-thread-toggle" data-act="toggle"><span class="cw-check">✓</span> Resolved${expanded ? '' : ` · <span class="cw-thread-summary">${escHtml(summary)}</span>`}<span class="cw-thread-count">${c.replies.length ? `${c.replies.length + 1} messages` : ''}</span></button>`;
602602 }
603603 if (expanded) {
604604 if (quote) {
605605 html += `<div class="cw-quote"><div class="cw-quote-head"><code>${escHtml(a.path || '')}</code> ${escHtml(a.range || '')}${a.section ? ` · ${escHtml(a.section)}` : ''}</div><pre>${(a.lines || []).map((l) => `<span class="${l.t === '+' ? 'add' : l.t === '-' ? 'del' : ''}">${escHtml(l.t === ' ' ? ' ' : `${l.t} `)}${escHtml(l.text)}</span>`).join('\n')}</pre></div>`;
606606 }
607607 html += messageHtml(c, { id: c.id, isRoot: true });
608608 for (const r of c.replies) html += messageHtml(r, { id: c.id, isRoot: false });
609609 // While a message is being edited, its Cancel / Save take the place of Reply / Resolve.
610610 const editKey = [...review.editing].find((key) => key === c.id || key.startsWith(`${c.id}/`));
611611 if (editKey) {
612612 node.classList.add('editing');
613613 const k = escHtml(editKey);
614614 html += `<div class="cw-thread-actions editing"><button type="button" class="cw-btn ghost" data-act="cancel-edit" data-key="${k}">Cancel</button><button type="button" class="cw-btn ghost save" data-act="save-edit" data-key="${k}">Save</button><span class="cw-hint">⌘↩ to save · Esc to cancel</span></div>`;
615615 } else if (review.replying.has(c.id)) {
616616 html += `<div class="cw-composer inline reply"><textarea rows="2" placeholder="Reply
" data-draft="reply:${c.id}"></textarea><div class="cw-composer-actions"><span class="cw-hint">⌘↩ to send</span><button type="button" class="cw-btn" data-act="cancel-reply">Cancel</button><button type="button" class="cw-btn primary" data-act="send-reply">Reply</button></div></div>`;
617617 } else {
618618 html += `<div class="cw-thread-actions"><button type="button" class="cw-btn ghost" data-act="reply">Reply</button><button type="button" class="cw-btn ghost" data-act="${resolved ? 'reopen' : 'resolve'}">${resolved ? 'Reopen' : 'Resolve'}</button><button type="button" class="cw-btn ghost cw-thread-link" data-act="copy-link" title="Copy link to this comment">Link</button></div>`;
619619 }
620620 }
621621 node.innerHTML = html;
622622 return node;
623623 }
624624
625625 function renderThreads() {
626626 // Keep drafts and focus across re-renders (e.g. when Claude replies from the CLI).
627627 const active = document.activeElement;
628628 const activeKey = active?.dataset?.draft;
629629 const caret = activeKey ? [active.selectionStart, active.selectionEnd] : null;
630630 $$('textarea[data-draft]').forEach((t) => review.drafts.set(t.dataset.draft, t.value));
631631 if (composer) review.drafts.set('new', composer.row.querySelector('textarea').value);
632632
633633 $$('.cw-thread-row, .cw-unplaced').forEach((n) => n.remove());
634− $$('tr.commented').forEach((r) => r.classList.remove('commented', 'resolved-only'));
634+ $$('tr.commented').forEach((r) => r.classList.remove('commented', 'resolved-only', 'cm-start', 'cm-end'));
635635
636636 const groups = new Map();
637637 const unplaced = [];
638638 for (const c of review.comments) {
639639 const a = c.anchor;
640640 const fig = a && a.link && a.range ? findSnippet(a.link, a.range) : null;
641641 const span = fig && spanFor(fig, a.range);
642642 if (!span) { unplaced.push(c); continue; }
643643 const rows = rowsOf(fig);
644644 const endRow = rows[span.end - 1];
645645 if (!groups.has(endRow)) groups.set(endRow, []);
646646 groups.get(endRow).push(c);
647647 for (let i = span.start; i < span.end; i++) {
648648 const r = rows[i];
649649 if (c.status !== 'resolved') {
650650 r.classList.add('commented');
651651 r.classList.remove('resolved-only');
652652 openFold(r);
653653 } else if (!r.classList.contains('commented')) {
654654 r.classList.add('commented', 'resolved-only');
655655 }
656656 }
657+ rows[span.start].classList.add('cm-start');
658+ rows[span.end - 1].classList.add('cm-end');
657659 }
658660 for (const [endRow, list] of groups) {
659661 const tr = el('tr', 'cw-comment-row cw-thread-row');
660662 const td = el('td');
661663 td.colSpan = endRow.children.length;
662664 const wrap = el('div', 'cw-threads');
663665 list.forEach((c) => wrap.appendChild(threadEl(c)));
664666 td.appendChild(wrap);
665667 tr.appendChild(td);
666668 endRow.after(tr);
667669 }
668670 if (unplaced.length) {
669671 const sec = el('section', 'cw-unplaced');
670672 sec.innerHTML = '<h2>Comments on code no longer in this walk</h2>';
671673 unplaced.forEach((c) => sec.appendChild(threadEl(c, { quote: true })));
672674 document.querySelector('.cw-article')?.appendChild(sec);
673675 }
674676
675677 for (const [key, value] of review.drafts) {
676678 const t = document.querySelector(`textarea[data-draft="${CSS.escape(key)}"]`);
677679 if (t) t.value = value;
678680 }
679681 for (const key of review.editing) {
680682 const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
681683 const m = messageFor(key);
682684 if (t && !review.drafts.has(`edit:${key}`) && m) t.value = m.body;
683685 }
684686 $$('.cw-composer textarea').forEach(autosize);
685687 if (activeKey) {
686688 const t = document.querySelector(`textarea[data-draft="${CSS.escape(activeKey)}"]`);
687689 if (t) { t.focus({ preventScroll: true }); if (caret) t.setSelectionRange(caret[0], caret[1]); }
688690 }
689691 }
690692
691693 /** Grow a textarea to fit its content so the whole comment is visible while editing. */
692694 function autosize(t) {
693695 t.style.height = 'auto';
694696 t.style.height = `${t.scrollHeight + (t.offsetHeight - t.clientHeight)}px`;
695697 }
696698
697699 function messageFor(key) {
698700 const [id, replyId] = key.split('/');
699701 const c = review.comments.find((x) => x.id === id);
700702 return c && (replyId ? c.replies.find((r) => r.id === replyId) : c);
701703 }
702704
703705 // -- thread actions --
704706 document.addEventListener('click', async (e) => {
705707 const btn = e.target.closest('.cw-thread button, .cw-composer-row button');
706708 if (!btn) return;
707709 const act = btn.dataset.act;
708710 if (act === 'cancel') { review.drafts.delete('new'); return closeComposer(); }
709711 if (act === 'save') return saveComposer();
710712 const thread = btn.closest('.cw-thread');
711713 if (!thread) return;
712714 const id = thread.dataset.id;
713715 const c = review.comments.find((x) => x.id === id);
714716 const replace = (updated) => {
715717 const i = review.comments.findIndex((x) => x.id === id);
716718 if (i !== -1) review.comments[i] = updated;
717719 };
718720 try {
719721 if (act === 'toggle') {
720722 review.expanded ??= new Set();
721723 if (review.expanded.has(id)) review.expanded.delete(id); else review.expanded.add(id);
722724 renderThreads();
723725 } else if (act === 'reply') {
724726 review.replying.add(id);
725727 renderThreads();
726728 document.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`)?.focus();
727729 } else if (act === 'cancel-reply') {
728730 review.replying.delete(id);
729731 review.drafts.delete(`reply:${id}`);
730732 renderThreads();
731733 } else if (act === 'send-reply') {
732734 const t = thread.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`);
733735 if (!t.value.trim()) return t.focus();
734736 btn.disabled = true;
735737 const { comment } = await api('/reply', { id, body: { body: t.value } });
736738 review.replying.delete(id);
737739 review.drafts.delete(`reply:${id}`);
738740 replace(comment);
739741 renderThreads();
740742 } else if (act === 'resolve' || act === 'reopen') {
741743 const { comment } = await api('/status', { id, body: { status: act === 'resolve' ? 'resolved' : 'open' } });
742744 replace(comment);
743745 review.expanded?.delete(id);
744746 renderThreads();
745747 toast(act === 'resolve' ? 'Resolved' : 'Reopened');
746748 } else if (act === 'edit') {
747749 const key = btn.dataset.key;
748750 // One message per thread at a time, since the thread's action row holds its Save / Cancel.
749751 for (const other of [...review.editing]) if (other.split('/')[0] === id) review.editing.delete(other);
750752 review.replying.delete(id);
751753 review.editing.add(key);
752754 review.editBase.set(key, messageFor(key)?.body ?? '');
753755 renderThreads();
754756 const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
755757 // Keep the page still: the editor takes the text's place and shows all of it.
756758 if (t) { t.focus({ preventScroll: true }); t.setSelectionRange(t.value.length, t.value.length); }
757759 } else if (act === 'cancel-edit') {
758760 const key = btn.dataset.key;
759761 review.editing.delete(key);
760762 review.editBase.delete(key);
761763 review.drafts.delete(`edit:${key}`);
762764 renderThreads();
763765 } else if (act === 'keep-mine') {
764766 // The user has now seen the latest text, so it becomes the base for their save.
765767 const key = btn.dataset.key;
766768 review.editBase.set(key, messageFor(key)?.body ?? '');
767769 thread.querySelector(`[data-act="save-edit"][data-key="${CSS.escape(key)}"]`)?.click();
768770 } else if (act === 'take-latest') {
769771 const key = btn.dataset.key;
770772 const latest = messageFor(key)?.body ?? '';
771773 review.editBase.set(key, latest);
772774 review.drafts.set(`edit:${key}`, latest);
773775 thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`).value = latest;
774776 renderThreads();
775777 } else if (act === 'save-edit') {
776778 const key = btn.dataset.key;
777779 const t = thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
778780 if (!t.value.trim()) return t.focus();
779781 btn.disabled = true;
780782 try {
781783 const { comment } = await api('/edit', { id, body: { body: t.value, reply: key.split('/')[1] || null, base: review.editBase.get(key) } });
782784 review.editing.delete(key);
783785 review.editBase.delete(key);
784786 review.drafts.delete(`edit:${key}`);
785787 replace(comment);
786788 } catch (err) {
787789 if (err.status !== 409) throw err;
788790 // Someone else changed it: keep the draft, show their latest text, let the user choose.
789791 const m = messageFor(key);
790792 if (m) m.body = err.data.current;
791793 toast('Not saved: this message changed while you were editing');
792794 }
793795 renderThreads();
794796 } else if (act === 'delete') {
795797 // Inline confirmation: native confirm() is blocked in some embedded browsers.
796798 review.confirmingDelete = btn.dataset.key;
797799 renderThreads();
798800 document.querySelector(`.cw-msg[data-key="${CSS.escape(btn.dataset.key)}"] [data-act="confirm-delete"]`)?.focus();
799801 } else if (act === 'cancel-delete') {
800802 review.confirmingDelete = null;
801803 renderThreads();
802804 } else if (act === 'confirm-delete') {
803805 const replyId = btn.dataset.key.split('/')[1] || null;
804806 btn.disabled = true;
805807 const { comment } = await api('/delete', { id, body: { reply: replyId } });
806808 review.confirmingDelete = null;
807809 if (replyId) replace(comment);
808810 else review.comments = review.comments.filter((x) => x.id !== id);
809811 renderThreads();
810812 toast(replyId ? 'Reply deleted' : c && c.replies.length ? 'Thread deleted' : 'Comment deleted');
811813 } else if (act === 'copy-link') {
812814 copy(`${location.origin}${location.pathname}#comment-${id}`, 'Copied link to comment');
813815 }
814816 } catch (err) {
815817 btn.disabled = false;
816818 toast(err.message);
817819 }
818820 });
819821
820822 document.addEventListener('keydown', (e) => {
821823 const t = e.target;
822824 if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return;
823825 // Edit boxes keep their buttons in the thread's action row; other composers inline.
824826 const scope = t.closest('.cw-composer.edit') ? t.closest('.cw-thread') : t.closest('.cw-composer');
825827 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
826828 e.preventDefault();
827829 scope.querySelector('[data-act="save-edit"], .cw-btn.primary')?.click();
828830 } else if (e.key === 'Escape') {
829831 e.preventDefault();
830832 scope.querySelector('[data-act="cancel-edit"], .cw-btn:not(.primary)')?.click();
831833 }
832834 });
833835
834836 document.addEventListener('input', (e) => {
835837 const t = e.target;
836838 if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return;
837839 autosize(t);
838840 if (t.closest('.cw-composer-row')) review.drafts.set('new', t.value);
839841 });
840842
841843 // -- navigation between open threads (n / p) --
842844 function openThreads() {
843845 return $$('.cw-thread').filter((t) => !t.classList.contains('resolved'));
844846 }
845847
846848 let threadCursor = -1;
847849 function jumpToThread(dir) {
848850 const threads = openThreads();
849851 if (!threads.length) return toast(review.comments.length ? 'No open comments' : 'No comments yet — select lines and press c');
850852 const y = window.innerHeight * 0.3;
851853 if (dir > 0) threadCursor = threads.findIndex((t) => t.getBoundingClientRect().top > y + 4);
852854 else threadCursor = threads.map((t) => t.getBoundingClientRect().top < y - 4).lastIndexOf(true);
853855 if (threadCursor === -1) threadCursor = dir > 0 ? 0 : threads.length - 1;
854856 focusThread(threads[threadCursor].dataset.id);
855857 }
856858
857859 function focusThread(id) {
858860 let node = document.getElementById(`comment-${id}`);
859861 if (!node) {
860862 const hit = review.comments.find((c) => c.id.startsWith(id));
861863 node = hit && document.getElementById(`comment-${hit.id}`);
862864 }
863865 if (!node) return toast('That comment isn’t in this walk');
864866 openFold(node);
865867 const top = window.scrollY + node.getBoundingClientRect().top - window.innerHeight * 0.3;
866868 window.scrollTo({ top: Math.max(0, top) });
867869 node.classList.remove('flash');
868870 void node.offsetWidth;
869871 node.classList.add('flash');
870872 }
871873
872874 loadComments();
873875
874876 // ---- TOC active section -------------------------------------------------------------
875877 const tocLinks = $$('.cw-toc a');
876878 if (tocLinks.length) {
877879 const byId = new Map(tocLinks.map((a) => [a.dataset.id, a]));
878880 const hs = $$('.cw-article h2, .cw-article h3').filter((h) => byId.has(h.id));
879881 const update = () => {
880882 // Above the first heading, the title link at the top of the sidebar is the active one.
881883 let active = null;
882884 for (const h of hs) if (h.getBoundingClientRect().top < window.innerHeight * 0.25) active = h;
883885 const id = active ? active.id : '';
884886 tocLinks.forEach((a) => a.classList.toggle('active', a.dataset.id === id));
885887 };
886888 tocLinks[0].addEventListener('click', (e) => {
887889 e.preventDefault();
888890 window.scrollTo({ top: 0 });
889891 history.replaceState(null, '', location.pathname);
890892 });
891893 window.addEventListener('scroll', update, { passive: true });
892894 update();
893895 }
894896})();

Summaries

Add --stat for an overview: files, +/− counts and change bars, computed from git on every render. An optional YAML body sorts the files into groups with notes. This is the first feature release of Code Walk:

13 files · 1 commit+286−29
64b543f→9728c9egit diff --stat 64b543f 9728c9e
Remote reposWalks can reference a repo by git URL5 files +130−14
M src/git.js +17−2
A src/remote.js
Bare clones in a cache dir, fetched once per run
+78−0
M src/resolve.js +3−2
M src/stats.js +3−2
M src/walk.js
`repo:` accepts a URL
+29−8
Viewer3 files +49−13
M src/render.js +25−6
M web/app.css +3−1
M web/app.js +21−6
Docs and tests5 files +107−2
M README.md +16−1
M scripts/demo.js +2−0
M skills/code-walk/SKILL.md +12−1
M src/commands/check.js +3−0
M test/integration.test.js +74−0
Total 13 files+286−29

Click a line number to select it, and shift-click to select a range. The address bar updates to a link that describes the code on its own. Paste it into Claude, and Claude can read exactly those lines with code-walk resolve:

https://jeffbaumes.github.io/code-walk/#show+5c288d0872eb:src/refs.js&L21-24

Try it on this snippet, which parses those selections:

src/refs.js
5c288d0git show 5c288d0:src/refs.js
1// Parsing of reference strings: fenced-block info strings (`diff main...feature -- src/x.ts R30-35`)
2// and URL fragments (`diff+main...feature+--+src/x.ts&R30-35`). Pure syntax; no git access.
3
4export class RefError extends Error {}
5
6export const SELECTION_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/;
7
8export function tokenize(str) {
9 const tokens = [];
10 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
11 let m;
12 while ((m = re.exec(str))) {
13 if (m[1] !== undefined) tokens.push(m[1].replace(/\\(.)/g, '$1'));
14 else if (m[2] !== undefined) tokens.push(m[2]);
15 else tokens.push(m[3]);
16 }
17 return tokens;
18}
19
20export function parseSelection(token) {
21 const m = SELECTION_RE.exec(token);
22 if (!m) return null;
23 if (m[5] !== undefined) {
24 const n = Number(m[5]);
25 if (n < 1) throw new RefError(`hunk numbers start at 1: ${token}`);
26 return { type: 'hunk', n };
27 }
28 const start = { side: m[1], line: Number(m[2]) };
29 const end = m[4] !== undefined ? { side: m[3] || m[1], line: Number(m[4]) } : { ...start };
30 if (start.line < 1 || end.line < 1) throw new RefError(`line numbers start at 1: ${token}`);
31 if (start.side === end.side && end.line < start.line) {
32 throw new RefError(`range end is before its start: ${token}`);
33 }
34 if (start.side === 'R' && end.side === 'L') {
35 throw new RefError(`a cross-side range goes from L (old) to R (new), e.g. L12-R14: ${token}`);
36 }
37 return { type: 'range', start, end };
38}
39
40export function formatSelection(sel) {
41 if (!sel) return '';
42 if (sel.type === 'hunk') return `hunk=${sel.n}`;
43 const { start, end } = sel;
44 if (start.side === end.side) {
45 return start.line === end.line ? `${start.side}${start.line}` : `${start.side}${start.line}-${end.line}`;
46 }
47 return `${start.side}${start.line}-${end.side}${end.line}`;
48}
49
50/**
51 * Parse reference arguments into a syntactic description.
52 * { command: 'show'|'diff', repo, cached, revs, target, paths, selection }
53 * `target` is the single non-path argument of `show`.
54 */
55export function parseRef(input) {
56 const tokens = Array.isArray(input) ? [...input] : tokenize(input);
57 let repo = null;
58 const takeRepo = () => {
59 if (tokens[0] === '-C') {
60 if (tokens.length < 2) throw new RefError('-C needs a repo name or path');
61 if (repo !== null) throw new RefError('-C given twice');
62 repo = tokens[1];
63 tokens.splice(0, 2);
64 }
65 };
66 takeRepo();
67 const command = tokens.shift();
68 if (command !== 'show' && command !== 'diff') {
69 throw new RefError(`expected "show" or "diff", got ${command === undefined ? 'nothing' : `"${command}"`}`);
70 }
71 takeRepo();
72
73 let selection = null;
74 if (tokens.length && SELECTION_RE.test(tokens[tokens.length - 1])) {
75 selection = parseSelection(tokens.pop());
76 }
77
78 const dd = tokens.indexOf('--');
79 const before = dd === -1 ? tokens : tokens.slice(0, dd);
80 const paths = dd === -1 ? [] : tokens.slice(dd + 1);
81 if (dd !== -1 && paths.length === 0) throw new RefError('"--" must be followed by at least one path');
82
83 const ref = { command, repo, cached: false, stat: false, revs: [], target: null, paths, selection };
84
85 for (const tok of before) {
86 if (command === 'diff' && (tok === '--cached' || tok === '--staged')) {
87 ref.cached = true;
88 } else if (tok === '--stat') {
89 ref.stat = true;
90 } else if (tok.startsWith('-')) {
91 throw new RefError(`unsupported option "${tok}" (supported: -C <repo>, --stat${command === 'diff' ? ', --cached' : ''})`);
92 } else if (command === 'diff') {
93 ref.revs.push(tok);
94 } else if (ref.target === null) {
95 ref.target = tok;
96 } else {
97 throw new RefError(`show takes one argument (a commit, rev:path, :path or path), got "${ref.target}" and "${tok}"`);
98 }
99 }
100
101 if (command === 'show' && ref.target === null) {
102 throw new RefError('show needs an argument: <commit>, <rev>:<path>, :<path> (staged) or <path> (working tree)');
103 }
104 if (command === 'diff') {
105 if (ref.revs.length > 2) throw new RefError('diff takes at most two revisions');
106 if (ref.revs.length === 2 && ref.revs.some((r) => r.includes('..'))) {
107 throw new RefError('use either "A..B" or "A B", not both');
108 }
109 if (ref.cached && (ref.revs.length > 1 || ref.revs.some((r) => r.includes('..')))) {
110 throw new RefError('--cached takes at most one revision (compares it to the staged index)');
111 }
112 }
113 if (ref.stat) {
114 if (selection) throw new RefError('--stat summarizes whole files; remove the line selection');
115 if (command === 'show' && ref.target.includes(':')) throw new RefError('show --stat takes a commit, not <rev>:<path>');
116 }
117 for (const p of paths) {
118 if (p.startsWith('-')) throw new RefError(`paths may not start with "-": ${p}`);
119 }
120 return ref;
121}
122
123export function formatRef(ref) {
124 const parts = [];
125 if (ref.repo) parts.push('-C', ref.repo);
126 parts.push(ref.command);
127 if (ref.stat) parts.push('--stat');
128 if (ref.cached) parts.push('--cached');
129 parts.push(...ref.revs);
130 if (ref.target) parts.push(ref.target);
131 if (ref.paths.length) parts.push('--', ...ref.paths);
132 return parts.map(quoteToken).join(' ');
133}
134
135function quoteToken(t) {
136 return /[\s"']/.test(t) ? `"${t.replace(/["\\]/g, '\\$&')}"` : t;
137}
138
139// ---- URL fragments --------------------------------------------------------
140
141const FRAGMENT_SAFE = /[A-Za-z0-9_\-.~/:^@!,=]/;
142
143function encodeToken(t) {
144 let out = '';
145 for (const ch of t) {
146 out += FRAGMENT_SAFE.test(ch) ? ch : encodeURIComponent(ch).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
147 }
148 return out;
149}
150
151/** `["diff","a..b","--","x.ts"]`, selection → `diff+a..b+--+x.ts&R3-5` */
152export function encodeFragment(tokens, selection) {
153 const s = tokens.map(encodeToken).join('+');
154 return selection ? `${s}&${formatSelection(selection)}` : s;
155}
156
157/** Inverse of encodeFragment. Accepts a full URL or just the fragment. */
158export function decodeFragment(input) {
159 let frag = input;
160 const hash = input.indexOf('#');
161 if (hash !== -1) frag = input.slice(hash + 1);
162 let selection = null;
163 const amp = frag.lastIndexOf('&');
164 if (amp !== -1 && SELECTION_RE.test(frag.slice(amp + 1))) {
165 selection = parseSelection(frag.slice(amp + 1));
166 frag = frag.slice(0, amp);
167 }
168 const tokens = frag.split('+').filter(Boolean).map((t) => decodeURIComponent(t));
169 return { tokens, selection };
170}
Key Does
j / k Next / previous section
y Copy a link to the selected lines (or the snippet)
Y Copy a prompt-ready reference, including the command and repo
c Comment on the selected lines (with code-walk serve)
n / p Next / previous open comment
Esc Clear the selection

Review comments

With code-walk serve, select lines and press c to leave a comment. Comments are threaded under the code and saved next to the walk in <walk>.comments.json.

When you're done reading, tell Claude "address my code walk comments". It runs code-walk comments to get each open thread with the exact lines it's attached to, makes the changes, and replies with code-walk comments reply <id> "
" --resolve. Replies show up in the open page right away.

A published page (like this one) shows its comments read-only. Here's a thread from that comment-marker change:

web/app.js+3−1
a031379→5c288d0git diff a031379 5c288d0 -- web/app.js
11(() => {
22 const CFG = window.CODE_WALK || {};
33 const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
44 const SEL_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/;
55
66 // ---- toast ------------------------------------------------------------------
77 const toastEl = document.querySelector('.cw-toast');
88 let toastTimer;
99 function toast(msg) {
1010 if (!toastEl) return;
1111 toastEl.textContent = msg;
1212 toastEl.classList.add('show');
1313 clearTimeout(toastTimer);
1414 toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800);
1515 }
1616
1717 async function copy(text, msg) {
1818 try {
1919 await navigator.clipboard.writeText(text);
2020 } catch {
2121 const ta = document.createElement('textarea');
2222 ta.value = text;
2323 ta.style.position = 'fixed';
2424 ta.style.opacity = '0';
2525 document.body.appendChild(ta);
2626 ta.select();
2727 document.execCommand('copy');
2828 ta.remove();
2929 }
3030 toast(msg);
3131 }
3232
3333 // ---- live reload (keeps scroll position) -------------------------------------
3434 const SCROLL_KEY = `cw-scroll:${location.pathname}`;
3535 try {
3636 const y = sessionStorage.getItem(SCROLL_KEY);
3737 if (y !== null) {
3838 sessionStorage.removeItem(SCROLL_KEY);
3939 history.scrollRestoration = 'manual';
4040 window.__cwRestored = Number(y);
4141 }
4242 } catch { /* storage unavailable */ }
4343
4444 if (window.EventSource) {
4545 const es = new EventSource('/events');
4646 es.onmessage = (ev) => {
4747 let data = {};
4848 try { data = JSON.parse(ev.data); } catch { return; }
4949 if (data.kind === 'comments') {
5050 if (data.walk === CFG.walk) loadComments();
5151 return;
5252 }
5353 if (!CFG.walk || data.walk === CFG.walk) {
5454 try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch { /* ignore */ }
5555 location.reload();
5656 }
5757 };
5858 }
5959
6060 // ---- mermaid -------------------------------------------------------------------
6161 let layoutReady = Promise.resolve();
6262 if (window.mermaid) {
6363 const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
6464 const css = getComputedStyle(document.documentElement);
6565 window.mermaid.initialize({
6666 startOnLoad: false,
6767 securityLevel: 'strict',
6868 theme: dark ? 'dark' : 'neutral',
6969 fontFamily: css.getPropertyValue('--font').trim(),
7070 });
7171 layoutReady = window.mermaid.run({ querySelector: 'pre.mermaid' }).catch((e) => console.error(e));
7272 }
7373
7474 // ---- folds ------------------------------------------------------------------------
7575 function openFold(node) {
7676 const fold = node.closest('.cw-fold');
7777 if (!fold) return;
7878 fold.classList.add('open');
7979 fold.querySelector('.cw-expander button')?.setAttribute('aria-expanded', 'true');
8080 }
8181 document.addEventListener('click', (e) => {
8282 const btn = e.target.closest('.cw-expander button');
8383 if (!btn) return;
8484 const open = btn.closest('.cw-fold').classList.toggle('open');
8585 btn.setAttribute('aria-expanded', String(open));
8686 });
8787
8888 // ---- line selection ---------------------------------------------------------------
8989 let current = null; // { fig, anchor: {idx, side}, focus: {idx, side} }
9090
9191 const rowsOf = (fig) => $$('tr.r', fig);
9292
9393 function lineOf(row, side, mode) {
9494 const v = row.dataset[side === 'L' ? 'l' : 'r'];
9595 return v === undefined ? null : Number(v);
9696 }
9797
9898 function pickSide(row, preferred, mode) {
9999 if (mode === 'file') return 'L';
100100 if (lineOf(row, preferred) !== null) return preferred;
101101 return preferred === 'L' ? 'R' : 'L';
102102 }
103103
104104 function rangeFor(fig, a, b) {
105105 const rows = rowsOf(fig);
106106 const mode = fig.dataset.mode;
107107 let [s, e] = a.idx <= b.idx ? [a, b] : [b, a];
108108 const sr = rows[s.idx];
109109 const er = rows[e.idx];
110110 if (mode === 'file') {
111111 const x = lineOf(sr, 'L');
112112 const y = lineOf(er, 'L');
113113 return x === y ? `L${x}` : `L${x}-${y}`;
114114 }
115115 const has = (row, side) => lineOf(row, side) !== null;
116116 let ss = s.side;
117117 let es = e.side;
118118 const valid = (p, q) => has(sr, p) && has(er, q) && !(p === 'R' && q === 'L');
119119 if (!valid(ss, es)) {
120120 const options = [['L', 'L'], ['R', 'R'], ['L', 'R']];
121121 const found = options.find(([p, q]) => valid(p, q));
122122 if (found) [ss, es] = found;
123123 else {
124124 // Added lines followed by deleted lines: fall back to the new side.
125125 ss = 'R';
126126 es = 'R';
127127 let j = e.idx;
128128 while (j > s.idx && !has(rows[j], 'R')) j--;
129129 e = { idx: j };
130130 }
131131 }
132132 const x = lineOf(sr, ss);
133133 const y = lineOf(rows[e.idx], es);
134134 if (ss === es) return x === y ? `${ss}${x}` : `${ss}${x}-${y}`;
135135 return `${ss}${x}-${es}${y}`;
136136 }
137137
138138 /** Mirrors selectionSpan() on the server. Returns {start, end} (end exclusive) or null. */
139139 function spanFor(fig, range) {
140140 const m = SEL_RE.exec(range);
141141 if (!m || m[5]) return null;
142142 const rows = rowsOf(fig);
143143 const mode = fig.dataset.mode;
144144 const s = { side: mode === 'file' ? 'L' : m[1], line: Number(m[2]) };
145145 const e = m[4] ? { side: mode === 'file' ? 'L' : (m[3] || m[1]), line: Number(m[4]) } : { ...s };
146146 let start = -1;
147147 let end = -1;
148148 if (s.side === e.side) {
149149 rows.forEach((r, i) => {
150150 const ln = lineOf(r, s.side);
151151 if (ln !== null && ln >= s.line && ln <= e.line) {
152152 if (start === -1) start = i;
153153 end = i;
154154 }
155155 });
156156 } else {
157157 start = rows.findIndex((r) => lineOf(r, s.side) === s.line);
158158 for (let i = rows.length - 1; i >= 0; i--) if (lineOf(rows[i], e.side) === e.line) { end = i; break; }
159159 }
160160 if (start === -1 || end === -1 || end < start) return null;
161161 return { start, end: end + 1 };
162162 }
163163
164164 function clearSelection() {
165165 $$('tr.sel').forEach((r) => r.classList.remove('sel'));
166166 current = null;
167167 onSelectionChange();
168168 }
169169
170170 function paint(fig, span, { reveal = false, flash = false } = {}) {
171171 $$('tr.sel').forEach((r) => r.classList.remove('sel'));
172172 const rows = rowsOf(fig);
173173 for (let i = span.start; i < span.end; i++) {
174174 const r = rows[i];
175175 r.classList.add('sel');
176176 openFold(r);
177177 if (flash) {
178178 r.classList.remove('flash');
179179 void r.offsetWidth;
180180 r.classList.add('flash');
181181 }
182182 }
183183 onSelectionChange();
184184 if (reveal) {
185185 const first = rows[span.start];
186186 const rect = first.getBoundingClientRect();
187187 const target = window.scrollY + rect.top - Math.max(80, window.innerHeight * 0.3);
188188 window.scrollTo({ top: Math.max(0, target), behavior: 'instant' in window ? 'instant' : 'auto' });
189189 }
190190 }
191191
192192 function snippetUrl(fig, range) {
193193 const frag = fig.dataset.frag + (range ? `&${range}` : '');
194194 return `${location.origin}${location.pathname}#${frag}`;
195195 }
196196
197197 function updateUrl(fig, range) {
198198 history.replaceState(null, '', `#${fig.dataset.frag}${range ? `&${range}` : ''}`);
199199 }
200200
201201 document.addEventListener('click', (e) => {
202202 const cell = e.target.closest('.cw-code td.ln');
203203 if (!cell) return;
204204 const fig = cell.closest('.cw-snippet');
205205 const row = cell.closest('tr.r');
206206 const rows = rowsOf(fig);
207207 const idx = rows.indexOf(row);
208208 const side = pickSide(row, cell.dataset.side, fig.dataset.mode);
209209 if (!current || current.fig !== fig || !e.shiftKey) {
210210 if (current && current.fig === fig && current.anchor.idx === idx && current.focus.idx === idx && !e.shiftKey) {
211211 clearSelection();
212212 history.replaceState(null, '', location.pathname);
213213 return;
214214 }
215215 current = { fig, anchor: { idx, side }, focus: { idx, side } };
216216 } else {
217217 current.focus = { idx, side };
218218 window.getSelection()?.removeAllRanges();
219219 }
220220 const range = rangeFor(fig, current.anchor, current.focus);
221221 const span = spanFor(fig, range);
222222 if (span) paint(fig, span);
223223 current.range = range;
224224 updateUrl(fig, range);
225225 onSelectionChange();
226226 });
227227
228228 function decodeFragment(hash) {
229229 let frag = hash.replace(/^#/, '');
230230 let range = null;
231231 const amp = frag.lastIndexOf('&');
232232 if (amp !== -1 && SEL_RE.test(frag.slice(amp + 1))) {
233233 range = frag.slice(amp + 1);
234234 frag = frag.slice(0, amp);
235235 }
236236 let tokens;
237237 try {
238238 tokens = frag.split('+').filter(Boolean).map((t) => decodeURIComponent(t));
239239 } catch {
240240 return null;
241241 }
242242 return { tokens, range };
243243 }
244244
245245 function findSnippet(link, range) {
246246 const strip = (s) => s.replace(/^-C \S+ /, '');
247247 const figs = $$('.cw-snippet');
248248 let matches = figs.filter((f) => f.dataset.link === link);
249249 if (!matches.length) matches = figs.filter((f) => strip(f.dataset.link) === strip(link));
250250 // Prefer a snippet that already shows the lines without expanding folds.
251251 return matches.find((f) => {
252252 const span = range && spanFor(f, range);
253253 return span && rowsOf(f).slice(span.start, span.end).every((r) => !r.closest('.cw-fold'));
254254 }) || matches.find((f) => range && spanFor(f, range)) || (range ? null : matches[0]) || null;
255255 }
256256
257257 function applyHash({ flash = true } = {}) {
258258 const cm = /^#comment-([0-9a-f]+)$/.exec(location.hash);
259259 if (cm) return commentsReady.then(() => focusThread(cm[1]));
260260 const hash = location.hash;
261261 if (!hash || document.getElementById(decodeURIComponent(hash.slice(1)))) return;
262262 const d = decodeFragment(hash);
263263 if (!d || !d.tokens.length || !['show', 'diff', '-C'].includes(d.tokens[0])) return;
264264 const fig = findSnippet(d.tokens.join(' '), d.range);
265265 if (!fig) {
266266 toast('That snippet isn’t in this walk');
267267 return;
268268 }
269269 if (!d.range) {
270270 fig.scrollIntoView({ block: 'start' });
271271 return;
272272 }
273273 const span = spanFor(fig, d.range);
274274 if (!span) {
275275 toast(`Lines ${d.range} aren’t shown in that snippet`);
276276 fig.scrollIntoView({ block: 'start' });
277277 return;
278278 }
279279 const rows = rowsOf(fig);
280280 const sideOf = (r, pref) => pickSide(r, pref, fig.dataset.mode);
281281 const m = SEL_RE.exec(d.range);
282282 current = {
283283 fig,
284284 anchor: { idx: span.start, side: sideOf(rows[span.start], m[1]) },
285285 focus: { idx: span.end - 1, side: sideOf(rows[span.end - 1], m[3] || m[1]) },
286286 range: d.range,
287287 };
288288 paint(fig, span, { reveal: window.__cwRestored === undefined, flash });
289289 }
290290
291291 window.addEventListener('hashchange', () => applyHash());
292292 // Wait for diagrams to take their final size so scroll positions are stable.
293293 layoutReady.then(() => {
294294 if (window.__cwRestored !== undefined) window.scrollTo(0, window.__cwRestored);
295295 applyHash({ flash: window.__cwRestored === undefined });
296296 });
297297
298298 // ---- copying ----------------------------------------------------------------------
299299 function sectionTitle(fig) {
300300 let title = null;
301301 for (const h of $$('.cw-article h1, .cw-article h2, .cw-article h3')) {
302302 if (h.compareDocumentPosition(fig) & Node.DOCUMENT_POSITION_FOLLOWING) title = h.textContent.trim();
303303 else break;
304304 }
305305 return title;
306306 }
307307
308308 function promptText(fig, range) {
309309 const tokens = fig.dataset.link.replace(/^-C \S+ /, '');
310310 const repo = fig.dataset.repo;
311311 const lines = range ? ` lines ${range}` : '';
312312 const where = [
313313 CFG.walkPath ? `walk: ${CFG.walkPath}` : null,
314314 sectionTitle(fig) ? `section "${sectionTitle(fig)}"` : null,
315315 ].filter(Boolean).join(', ');
316316 let ref;
317317 if (/^show [^:\s]+$/.test(tokens)) ref = `${fig.dataset.path}${lines} (working tree of ${repo})`;
318318 else ref = `\`git -C ${repo} ${tokens}\`${lines}${range && fig.dataset.mode === 'diff' ? ' (L = old side, R = new side)' : ''}`;
319319 return `${ref}${where ? `\n${where}` : ''}\n${snippetUrl(fig, range)}`;
320320 }
321321
322322 function copyLink(fig, forPrompt) {
323323 const range = current && current.fig === fig ? current.range : null;
324324 if (forPrompt) copy(promptText(fig, range), 'Copied reference for a prompt');
325325 else copy(snippetUrl(fig, range), range ? `Copied link to ${range}` : 'Copied link to snippet');
326326 }
327327
328328 document.addEventListener('click', (e) => {
329329 const linkBtn = e.target.closest('.cw-link-btn');
330330 if (linkBtn) return copyLink(linkBtn.closest('.cw-snippet'), e.shiftKey);
331331 const cmdBtn = e.target.closest('.cw-copy-cmd');
332332 if (cmdBtn) {
333333 const code = cmdBtn.parentElement.querySelector('code');
334334 copy(code.textContent, 'Copied command');
335335 }
336336 });
337337
338338 // ---- keyboard ---------------------------------------------------------------------
339339 const headings = () => $$('.cw-article h2, .cw-article h3');
340340
341341 document.addEventListener('keydown', (e) => {
342342 if (e.metaKey || e.ctrlKey || e.altKey) return;
343343 if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable]')) return;
344344 if (e.key === 'c') {
345345 e.preventDefault();
346346 openComposer();
347347 } else if (e.key === 'n' || e.key === 'p') {
348348 jumpToThread(e.key === 'n' ? 1 : -1);
349349 } else if (e.key === 'y' || e.key === 'Y') {
350350 if (!current) return toast('Click a line number to select lines first');
351351 copyLink(current.fig, e.key === 'Y');
352352 } else if (e.key === 'j' || e.key === 'k') {
353353 const hs = headings();
354354 if (!hs.length) return;
355355 const y = 30;
356356 let target;
357357 if (e.key === 'j') target = hs.find((h) => h.getBoundingClientRect().top > y + 1);
358358 else target = [...hs].reverse().find((h) => h.getBoundingClientRect().top < y - 1);
359359 if (target) target.scrollIntoView({ block: 'start' });
360360 else if (e.key === 'k') window.scrollTo({ top: 0 });
361361 } else if (e.key === 'Escape' && current) {
362362 clearSelection();
363363 history.replaceState(null, '', location.pathname);
364364 }
365365 });
366366
367367 // ---- --stat summaries: files shown elsewhere in the walk link to their snippet --------
368368 for (const row of $$('.cw-sum-file')) {
369369 const repo = row.closest('.cw-summary').dataset.repo;
370370 const target = $$('.cw-snippet').find((f) => f.dataset.repo === repo && f.dataset.path === row.dataset.path);
371371 if (!target) continue;
372372 const label = row.querySelector('.cw-sum-path');
373373 const btn = document.createElement('button');
374374 btn.type = 'button';
375375 btn.className = 'cw-sum-path';
376376 btn.title = 'Jump to this file in the walk';
377377 btn.textContent = label.textContent;
378378 btn.addEventListener('click', () => {
379379 target.scrollIntoView({ block: 'start' });
380380 window.scrollBy(0, -24);
381381 target.classList.remove('flash');
382382 void target.offsetWidth;
383383 target.classList.add('flash');
384384 });
385385 label.replaceWith(btn);
386386 }
387387
388388 // ---- review comments -----------------------------------------------------------------
389389 // Threads are stored by the server in <walk>.comments.json and anchored to a snippet's
390390 // link + line range, with a copy of the lines, so Claude can act on them via the CLI.
391391 // editBase: the text each open editor started from. Saves send it so the server can refuse to
392392 // overwrite a message that changed meanwhile (e.g. Claude edited it from the CLI).
393393 const review = { me: 'You', comments: [], drafts: new Map(), replying: new Set(), editing: new Set(), editBase: new Map(), loaded: false };
394394 let resolveCommentsReady;
395395 const commentsReady = new Promise((r) => { resolveCommentsReady = r; });
396396
397397 const el = (tag, cls, html) => {
398398 const n = document.createElement(tag);
399399 if (cls) n.className = cls;
400400 if (html !== undefined) n.innerHTML = html;
401401 return n;
402402 };
403403 const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
404404
405405 function ago(iso) {
406406 const s = (Date.now() - new Date(iso).getTime()) / 1000;
407407 if (s < 45) return 'just now';
408408 if (s < 3600) return `${Math.round(s / 60)}m ago`;
409409 if (s < 86400) return `${Math.round(s / 3600)}h ago`;
410410 if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`;
411411 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
412412 }
413413
414414 async function api(action, { id, body } = {}) {
415415 const q = new URLSearchParams({ walk: CFG.walk });
416416 if (id) q.set('id', id);
417417 const res = await fetch(`/api/comments${action}?${q}`, {
418418 method: 'POST',
419419 headers: { 'content-type': 'application/json' },
420420 body: JSON.stringify(body || {}),
421421 });
422422 const data = await res.json().catch(() => ({}));
423423 if (!res.ok) throw Object.assign(new Error(data.error || `request failed (${res.status})`), { status: res.status, data });
424424 return data;
425425 }
426426
427427 async function loadComments() {
428428 if (!CFG.walk) return;
429429 try {
430430 const res = await fetch(`/api/comments?walk=${encodeURIComponent(CFG.walk)}`);
431431 if (!res.ok) return;
432432 const data = await res.json();
433433 review.me = data.me || 'You';
434434 review.comments = data.comments || [];
435435 review.loaded = true;
436436 renderThreads();
437437 } catch (e) {
438438 console.error(e);
439439 } finally {
440440 resolveCommentsReady();
441441 }
442442 }
443443
444444 function anchorFor(fig, range) {
445445 const span = spanFor(fig, range);
446446 const rows = rowsOf(fig).slice(span.start, span.end);
447447 const file = fig.dataset.mode === 'file';
448448 return {
449449 link: fig.dataset.link,
450450 range,
451451 path: fig.dataset.path,
452452 mode: fig.dataset.mode,
453453 repo: fig.dataset.repo,
454454 repoName: fig.dataset.repoName,
455455 command: fig.dataset.link,
456456 section: sectionTitle(fig),
457457 lines: rows.map((r) => ({
458458 o: !file && r.dataset.l ? Number(r.dataset.l) : null,
459459 n: file ? Number(r.dataset.l) : r.dataset.r ? Number(r.dataset.r) : null,
460460 t: r.classList.contains('add') ? '+' : r.classList.contains('del') ? '-' : ' ',
461461 text: r.querySelector('.code').textContent,
462462 })),
463463 };
464464 }
465465
466466 // -- selection toolbar --
467467 const selbar = el('div', 'cw-selbar');
468468 selbar.setAttribute('role', 'toolbar');
469469 document.body.appendChild(selbar);
470470
471471 function onSelectionChange() {
472472 if (!current || !current.range || !CFG.walk) {
473473 selbar.classList.remove('show');
474474 document.body.classList.remove('has-selbar');
475475 return;
476476 }
477477 selbar.innerHTML = `<span class="cw-selbar-range"><code>${escHtml(current.fig.dataset.path.split('/').pop())}</code> ${escHtml(current.range)}</span>`
478478 + '<button type="button" data-act="comment">Comment <kbd>c</kbd></button>'
479479 + '<button type="button" data-act="link">Copy link <kbd>y</kbd></button>'
480480 + '<button type="button" data-act="clear" aria-label="Clear selection" title="Clear selection (Esc)">✕</button>';
481481 selbar.classList.add('show');
482482 document.body.classList.add('has-selbar');
483483 }
484484
485485 selbar.addEventListener('click', (e) => {
486486 const act = e.target.closest('button')?.dataset.act;
487487 if (act === 'comment') openComposer();
488488 else if (act === 'link' && current) copyLink(current.fig, e.shiftKey);
489489 else if (act === 'clear') {
490490 clearSelection();
491491 history.replaceState(null, '', location.pathname);
492492 }
493493 });
494494
495495 // -- composer for a new comment --
496496 let composer = null; // { fig, range, row }
497497
498498 function closeComposer() {
499499 composer?.row.remove();
500500 composer = null;
501501 }
502502
503503 function openComposer() {
504504 if (!current || !current.range) return toast('Select lines first: click a line number (shift-click for a range)');
505505 const { fig, range } = current;
506506 const span = spanFor(fig, range);
507507 if (!span) return;
508508 if (composer && composer.fig === fig && composer.range === range) {
509509 composer.row.querySelector('textarea').focus();
510510 return;
511511 }
512512 closeComposer();
513513 const rows = rowsOf(fig);
514514 const cols = rows[0].children.length;
515515 const row = el('tr', 'cw-comment-row cw-composer-row');
516516 const td = el('td');
517517 td.colSpan = cols;
518518 td.innerHTML = `<div class="cw-composer">
519519 <div class="cw-composer-head">Comment on <b>${escHtml(range)}</b></div>
520520 <textarea rows="3" placeholder="Leave a review comment
 (Markdown supported)"></textarea>
521521 <div class="cw-composer-actions"><span class="cw-hint">⌘↩ to save · Esc to cancel</span><button type="button" class="cw-btn" data-act="cancel">Cancel</button><button type="button" class="cw-btn primary" data-act="save">Comment</button></div>
522522 </div>`;
523523 row.appendChild(td);
524524 rows[span.end - 1].after(row);
525525 composer = { fig, range, row };
526526 const ta = td.querySelector('textarea');
527527 ta.value = review.drafts.get('new') || '';
528528 ta.focus();
529529 }
530530
531531 async function saveComposer() {
532532 if (!composer) return;
533533 const ta = composer.row.querySelector('textarea');
534534 const body = ta.value.trim();
535535 if (!body) return ta.focus();
536536 const btn = composer.row.querySelector('[data-act="save"]');
537537 btn.disabled = true;
538538 try {
539539 const { comment } = await api('', { body: { anchor: anchorFor(composer.fig, composer.range), body } });
540540 review.drafts.delete('new');
541541 closeComposer();
542542 const first = !review.comments.length;
543543 review.comments.push(comment);
544544 renderThreads();
545545 toast(first ? 'Comment saved. When you’re done, ask Claude to “address my code walk comments”' : 'Comment saved');
546546 } catch (e) {
547547 btn.disabled = false;
548548 toast(e.message);
549549 }
550550 }
551551
552552 // -- thread rendering --
553553 function avatar(name) {
554554 const claude = /^claude$/i.test(name);
555555 return `<span class="cw-avatar${claude ? ' claude' : ''}" aria-hidden="true">${claude ? '✳' : escHtml((name || '?').trim().charAt(0).toUpperCase())}</span>`;
556556 }
557557
558558 // A message key is the thread id, or "<thread id>/<reply id>" for a reply.
559559 function messageHtml(m, { id, isRoot }) {
560560 const key = isRoot ? id : `${id}/${m.id}`;
561561 const k = escHtml(key);
562562 const editing = review.editing.has(key);
563563 const what = !isRoot ? 'reply' : m.replies?.length ? 'thread' : 'comment';
564564 const menu = review.confirmingDelete === key
565565 ? `<span class="cw-msg-menu confirming"><span class="cw-confirm-text">Delete ${what}?</span><button type="button" class="danger" data-act="confirm-delete" data-key="${k}">Delete</button><button type="button" data-act="cancel-delete">Cancel</button></span>`
566566 : `<span class="cw-msg-menu"><button type="button" data-act="edit" data-key="${k}">Edit</button><button type="button" data-act="delete" data-key="${k}"${what === 'thread' ? ' title="Deletes the whole thread"' : ''}>Delete</button></span>`;
567567 return `<div class="cw-msg${isRoot ? '' : ' reply'}" data-key="${k}">
568568 ${avatar(m.author)}
569569 <div class="cw-msg-main">
570570 <div class="cw-msg-meta"><b>${escHtml(m.author)}</b><time datetime="${escHtml(m.created)}" title="${escHtml(new Date(m.created).toLocaleString())}">${ago(m.created)}</time>${m.edited ? '<span class="cw-edited">edited</span>' : ''}${menu}</div>
571571 ${editing
572572 ? `<div class="cw-composer inline edit">${conflictHtml(key, m)}<textarea rows="1" data-draft="edit:${k}"></textarea></div>`
573573 : `<div class="cw-msg-body">${m.bodyHtml}</div>`}
574574 </div>
575575 </div>`;
576576 }
577577
578578 /** Shown in an open editor when the message changed after editing started. */
579579 function conflictHtml(key, m) {
580580 const base = review.editBase.get(key);
581581 if (base === undefined || base === m.body) return '';
582582 const k = escHtml(key);
583583 return `<div class="cw-conflict" role="alert">
584584 <div class="cw-conflict-head">This message changed while you were editing. Your text below hasn’t been saved.</div>
585585 <div class="cw-conflict-label">Latest version</div>
586586 <div class="cw-conflict-current">${escHtml(m.body)}</div>
587587 <div class="cw-conflict-actions"><button type="button" class="cw-btn ghost save" data-act="keep-mine" data-key="${k}">Save mine anyway</button><button type="button" class="cw-btn ghost" data-act="take-latest" data-key="${k}">Edit the latest instead</button></div>
588588 </div>`;
589589 }
590590
591591 function threadEl(c, { quote = false } = {}) {
592592 const resolved = c.status === 'resolved';
593593 const expanded = !resolved || review.expanded?.has(c.id);
594594 const node = el('div', `cw-thread${resolved ? ' resolved' : ''}${expanded ? '' : ' collapsed'}`);
595595 node.id = `comment-${c.id}`;
596596 node.dataset.id = c.id;
597597 const a = c.anchor || {};
598598 const summary = (c.body || '').split('\n')[0].slice(0, 120);
599599 let html = '';
600600 if (resolved) {
601601 html += `<button type="button" class="cw-thread-toggle" data-act="toggle"><span class="cw-check">✓</span> Resolved${expanded ? '' : ` · <span class="cw-thread-summary">${escHtml(summary)}</span>`}<span class="cw-thread-count">${c.replies.length ? `${c.replies.length + 1} messages` : ''}</span></button>`;
602602 }
603603 if (expanded) {
604604 if (quote) {
605605 html += `<div class="cw-quote"><div class="cw-quote-head"><code>${escHtml(a.path || '')}</code> ${escHtml(a.range || '')}${a.section ? ` · ${escHtml(a.section)}` : ''}</div><pre>${(a.lines || []).map((l) => `<span class="${l.t === '+' ? 'add' : l.t === '-' ? 'del' : ''}">${escHtml(l.t === ' ' ? ' ' : `${l.t} `)}${escHtml(l.text)}</span>`).join('\n')}</pre></div>`;
606606 }
607607 html += messageHtml(c, { id: c.id, isRoot: true });
608608 for (const r of c.replies) html += messageHtml(r, { id: c.id, isRoot: false });
609609 // While a message is being edited, its Cancel / Save take the place of Reply / Resolve.
610610 const editKey = [...review.editing].find((key) => key === c.id || key.startsWith(`${c.id}/`));
611611 if (editKey) {
612612 node.classList.add('editing');
613613 const k = escHtml(editKey);
614614 html += `<div class="cw-thread-actions editing"><button type="button" class="cw-btn ghost" data-act="cancel-edit" data-key="${k}">Cancel</button><button type="button" class="cw-btn ghost save" data-act="save-edit" data-key="${k}">Save</button><span class="cw-hint">⌘↩ to save · Esc to cancel</span></div>`;
615615 } else if (review.replying.has(c.id)) {
616616 html += `<div class="cw-composer inline reply"><textarea rows="2" placeholder="Reply
" data-draft="reply:${c.id}"></textarea><div class="cw-composer-actions"><span class="cw-hint">⌘↩ to send</span><button type="button" class="cw-btn" data-act="cancel-reply">Cancel</button><button type="button" class="cw-btn primary" data-act="send-reply">Reply</button></div></div>`;
617617 } else {
618618 html += `<div class="cw-thread-actions"><button type="button" class="cw-btn ghost" data-act="reply">Reply</button><button type="button" class="cw-btn ghost" data-act="${resolved ? 'reopen' : 'resolve'}">${resolved ? 'Reopen' : 'Resolve'}</button><button type="button" class="cw-btn ghost cw-thread-link" data-act="copy-link" title="Copy link to this comment">Link</button></div>`;
619619 }
620620 }
621621 node.innerHTML = html;
622622 return node;
623623 }
624624
625625 function renderThreads() {
626626 // Keep drafts and focus across re-renders (e.g. when Claude replies from the CLI).
627627 const active = document.activeElement;
628628 const activeKey = active?.dataset?.draft;
629629 const caret = activeKey ? [active.selectionStart, active.selectionEnd] : null;
630630 $$('textarea[data-draft]').forEach((t) => review.drafts.set(t.dataset.draft, t.value));
631631 if (composer) review.drafts.set('new', composer.row.querySelector('textarea').value);
632632
633633 $$('.cw-thread-row, .cw-unplaced').forEach((n) => n.remove());
634− $$('tr.commented').forEach((r) => r.classList.remove('commented', 'resolved-only'));
634+ $$('tr.commented').forEach((r) => r.classList.remove('commented', 'resolved-only', 'cm-start', 'cm-end'));
635635
636636 const groups = new Map();
637637 const unplaced = [];
638638 for (const c of review.comments) {
639639 const a = c.anchor;
640640 const fig = a && a.link && a.range ? findSnippet(a.link, a.range) : null;
641641 const span = fig && spanFor(fig, a.range);
642642 if (!span) { unplaced.push(c); continue; }
643643 const rows = rowsOf(fig);
644644 const endRow = rows[span.end - 1];
645645 if (!groups.has(endRow)) groups.set(endRow, []);
646646 groups.get(endRow).push(c);
647647 for (let i = span.start; i < span.end; i++) {
648648 const r = rows[i];
649649 if (c.status !== 'resolved') {
650650 r.classList.add('commented');
651651 r.classList.remove('resolved-only');
652652 openFold(r);
653653 } else if (!r.classList.contains('commented')) {
654654 r.classList.add('commented', 'resolved-only');
655655 }
656656 }
657+ rows[span.start].classList.add('cm-start');
658+ rows[span.end - 1].classList.add('cm-end');
657659 }
658660 for (const [endRow, list] of groups) {
659661 const tr = el('tr', 'cw-comment-row cw-thread-row');
660662 const td = el('td');
661663 td.colSpan = endRow.children.length;
662664 const wrap = el('div', 'cw-threads');
663665 list.forEach((c) => wrap.appendChild(threadEl(c)));
664666 td.appendChild(wrap);
665667 tr.appendChild(td);
666668 endRow.after(tr);
667669 }
668670 if (unplaced.length) {
669671 const sec = el('section', 'cw-unplaced');
670672 sec.innerHTML = '<h2>Comments on code no longer in this walk</h2>';
671673 unplaced.forEach((c) => sec.appendChild(threadEl(c, { quote: true })));
672674 document.querySelector('.cw-article')?.appendChild(sec);
673675 }
674676
675677 for (const [key, value] of review.drafts) {
676678 const t = document.querySelector(`textarea[data-draft="${CSS.escape(key)}"]`);
677679 if (t) t.value = value;
678680 }
679681 for (const key of review.editing) {
680682 const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
681683 const m = messageFor(key);
682684 if (t && !review.drafts.has(`edit:${key}`) && m) t.value = m.body;
683685 }
684686 $$('.cw-composer textarea').forEach(autosize);
685687 if (activeKey) {
686688 const t = document.querySelector(`textarea[data-draft="${CSS.escape(activeKey)}"]`);
687689 if (t) { t.focus({ preventScroll: true }); if (caret) t.setSelectionRange(caret[0], caret[1]); }
688690 }
689691 }
690692
691693 /** Grow a textarea to fit its content so the whole comment is visible while editing. */
692694 function autosize(t) {
693695 t.style.height = 'auto';
694696 t.style.height = `${t.scrollHeight + (t.offsetHeight - t.clientHeight)}px`;
695697 }
696698
697699 function messageFor(key) {
698700 const [id, replyId] = key.split('/');
699701 const c = review.comments.find((x) => x.id === id);
700702 return c && (replyId ? c.replies.find((r) => r.id === replyId) : c);
701703 }
702704
703705 // -- thread actions --
704706 document.addEventListener('click', async (e) => {
705707 const btn = e.target.closest('.cw-thread button, .cw-composer-row button');
706708 if (!btn) return;
707709 const act = btn.dataset.act;
708710 if (act === 'cancel') { review.drafts.delete('new'); return closeComposer(); }
709711 if (act === 'save') return saveComposer();
710712 const thread = btn.closest('.cw-thread');
711713 if (!thread) return;
712714 const id = thread.dataset.id;
713715 const c = review.comments.find((x) => x.id === id);
714716 const replace = (updated) => {
715717 const i = review.comments.findIndex((x) => x.id === id);
716718 if (i !== -1) review.comments[i] = updated;
717719 };
718720 try {
719721 if (act === 'toggle') {
720722 review.expanded ??= new Set();
721723 if (review.expanded.has(id)) review.expanded.delete(id); else review.expanded.add(id);
722724 renderThreads();
723725 } else if (act === 'reply') {
724726 review.replying.add(id);
725727 renderThreads();
726728 document.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`)?.focus();
727729 } else if (act === 'cancel-reply') {
728730 review.replying.delete(id);
729731 review.drafts.delete(`reply:${id}`);
730732 renderThreads();
731733 } else if (act === 'send-reply') {
732734 const t = thread.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`);
733735 if (!t.value.trim()) return t.focus();
734736 btn.disabled = true;
735737 const { comment } = await api('/reply', { id, body: { body: t.value } });
736738 review.replying.delete(id);
737739 review.drafts.delete(`reply:${id}`);
738740 replace(comment);
739741 renderThreads();
740742 } else if (act === 'resolve' || act === 'reopen') {
741743 const { comment } = await api('/status', { id, body: { status: act === 'resolve' ? 'resolved' : 'open' } });
742744 replace(comment);
743745 review.expanded?.delete(id);
744746 renderThreads();
745747 toast(act === 'resolve' ? 'Resolved' : 'Reopened');
746748 } else if (act === 'edit') {
747749 const key = btn.dataset.key;
748750 // One message per thread at a time, since the thread's action row holds its Save / Cancel.
749751 for (const other of [...review.editing]) if (other.split('/')[0] === id) review.editing.delete(other);
750752 review.replying.delete(id);
751753 review.editing.add(key);
752754 review.editBase.set(key, messageFor(key)?.body ?? '');
753755 renderThreads();
754756 const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
755757 // Keep the page still: the editor takes the text's place and shows all of it.
756758 if (t) { t.focus({ preventScroll: true }); t.setSelectionRange(t.value.length, t.value.length); }
757759 } else if (act === 'cancel-edit') {
758760 const key = btn.dataset.key;
759761 review.editing.delete(key);
760762 review.editBase.delete(key);
761763 review.drafts.delete(`edit:${key}`);
762764 renderThreads();
763765 } else if (act === 'keep-mine') {
764766 // The user has now seen the latest text, so it becomes the base for their save.
765767 const key = btn.dataset.key;
766768 review.editBase.set(key, messageFor(key)?.body ?? '');
767769 thread.querySelector(`[data-act="save-edit"][data-key="${CSS.escape(key)}"]`)?.click();
768770 } else if (act === 'take-latest') {
769771 const key = btn.dataset.key;
770772 const latest = messageFor(key)?.body ?? '';
771773 review.editBase.set(key, latest);
772774 review.drafts.set(`edit:${key}`, latest);
773775 thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`).value = latest;
774776 renderThreads();
775777 } else if (act === 'save-edit') {
776778 const key = btn.dataset.key;
777779 const t = thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`);
778780 if (!t.value.trim()) return t.focus();
779781 btn.disabled = true;
780782 try {
781783 const { comment } = await api('/edit', { id, body: { body: t.value, reply: key.split('/')[1] || null, base: review.editBase.get(key) } });
782784 review.editing.delete(key);
783785 review.editBase.delete(key);
784786 review.drafts.delete(`edit:${key}`);
785787 replace(comment);
786788 } catch (err) {
787789 if (err.status !== 409) throw err;
788790 // Someone else changed it: keep the draft, show their latest text, let the user choose.
789791 const m = messageFor(key);
790792 if (m) m.body = err.data.current;
791793 toast('Not saved: this message changed while you were editing');
792794 }
793795 renderThreads();
794796 } else if (act === 'delete') {
795797 // Inline confirmation: native confirm() is blocked in some embedded browsers.
796798 review.confirmingDelete = btn.dataset.key;
797799 renderThreads();
798800 document.querySelector(`.cw-msg[data-key="${CSS.escape(btn.dataset.key)}"] [data-act="confirm-delete"]`)?.focus();
799801 } else if (act === 'cancel-delete') {
800802 review.confirmingDelete = null;
801803 renderThreads();
802804 } else if (act === 'confirm-delete') {
803805 const replyId = btn.dataset.key.split('/')[1] || null;
804806 btn.disabled = true;
805807 const { comment } = await api('/delete', { id, body: { reply: replyId } });
806808 review.confirmingDelete = null;
807809 if (replyId) replace(comment);
808810 else review.comments = review.comments.filter((x) => x.id !== id);
809811 renderThreads();
810812 toast(replyId ? 'Reply deleted' : c && c.replies.length ? 'Thread deleted' : 'Comment deleted');
811813 } else if (act === 'copy-link') {
812814 copy(`${location.origin}${location.pathname}#comment-${id}`, 'Copied link to comment');
813815 }
814816 } catch (err) {
815817 btn.disabled = false;
816818 toast(err.message);
817819 }
818820 });
819821
820822 document.addEventListener('keydown', (e) => {
821823 const t = e.target;
822824 if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return;
823825 // Edit boxes keep their buttons in the thread's action row; other composers inline.
824826 const scope = t.closest('.cw-composer.edit') ? t.closest('.cw-thread') : t.closest('.cw-composer');
825827 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
826828 e.preventDefault();
827829 scope.querySelector('[data-act="save-edit"], .cw-btn.primary')?.click();
828830 } else if (e.key === 'Escape') {
829831 e.preventDefault();
830832 scope.querySelector('[data-act="cancel-edit"], .cw-btn:not(.primary)')?.click();
831833 }
832834 });
833835
834836 document.addEventListener('input', (e) => {
835837 const t = e.target;
836838 if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return;
837839 autosize(t);
838840 if (t.closest('.cw-composer-row')) review.drafts.set('new', t.value);
839841 });
840842
841843 // -- navigation between open threads (n / p) --
842844 function openThreads() {
843845 return $$('.cw-thread').filter((t) => !t.classList.contains('resolved'));
844846 }
845847
846848 let threadCursor = -1;
847849 function jumpToThread(dir) {
848850 const threads = openThreads();
849851 if (!threads.length) return toast(review.comments.length ? 'No open comments' : 'No comments yet — select lines and press c');
850852 const y = window.innerHeight * 0.3;
851853 if (dir > 0) threadCursor = threads.findIndex((t) => t.getBoundingClientRect().top > y + 4);
852854 else threadCursor = threads.map((t) => t.getBoundingClientRect().top < y - 4).lastIndexOf(true);
853855 if (threadCursor === -1) threadCursor = dir > 0 ? 0 : threads.length - 1;
854856 focusThread(threads[threadCursor].dataset.id);
855857 }
856858
857859 function focusThread(id) {
858860 let node = document.getElementById(`comment-${id}`);
859861 if (!node) {
860862 const hit = review.comments.find((c) => c.id.startsWith(id));
861863 node = hit && document.getElementById(`comment-${hit.id}`);
862864 }
863865 if (!node) return toast('That comment isn’t in this walk');
864866 openFold(node);
865867 const top = window.scrollY + node.getBoundingClientRect().top - window.innerHeight * 0.3;
866868 window.scrollTo({ top: Math.max(0, top) });
867869 node.classList.remove('flash');
868870 void node.offsetWidth;
869871 node.classList.add('flash');
870872 }
871873
872874 loadComments();
873875
874876 // ---- TOC active section -------------------------------------------------------------
875877 const tocLinks = $$('.cw-toc a');
876878 if (tocLinks.length) {
877879 const byId = new Map(tocLinks.map((a) => [a.dataset.id, a]));
878880 const hs = $$('.cw-article h2, .cw-article h3').filter((h) => byId.has(h.id));
879881 const update = () => {
880882 // Above the first heading, the title link at the top of the sidebar is the active one.
881883 let active = null;
882884 for (const h of hs) if (h.getBoundingClientRect().top < window.innerHeight * 0.25) active = h;
883885 const id = active ? active.id : '';
884886 tocLinks.forEach((a) => a.classList.toggle('active', a.dataset.id === id));
885887 };
886888 tocLinks[0].addEventListener('click', (e) => {
887889 e.preventDefault();
888890 window.scrollTo({ top: 0 });
889891 history.replaceState(null, '', location.pathname);
890892 });
891893 window.addEventListener('scroll', update, { passive: true });
892894 update();
893895 }
894896})();

Publish a walk

code-walk build renders a walk to a single self-contained HTML file, with the styles and scripts inline and no server needed. Existing comments are included read-only; pass --no-comments to leave them out. Given a directory, it builds every walk in it plus an index page.

code-walk build docs/index.md -o docs/index.html

That's the command behind this page: a GitHub Actions workflow runs it on every push to main and publishes the result to GitHub Pages.

Reference

Block Shows
show <rev>:<path> [L20-24] A file at a commit
show :<path> / show <path> The staged file / the working-tree file
show <commit> [-- <path>] A commit's message and diff
diff A..B / diff A B Commit to commit
diff A...B Merge base to B (what a PR shows)
diff --cached [A] / diff [A] / diff To staged / to working tree / staged to working tree

 --stat A summary, optionally grouped

Options go in the block body (highlight: 22-24). A walk can span several repos, local or by git URL, and can include Mermaid diagrams. The README has the details.

code-walk serve <walk.md|dir> [--open]       Serve walks with live reload and comments
code-walk build <walk.md|dir> [-o <out>]     Render to static HTML
code-walk check <walk.md...>                 Verify every reference resolves
code-walk outline [diff args]                Changed files and hunks, in walk syntax
code-walk comments [reply|resolve|add|edit]  Read and answer review comments