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:
| 1 | // Markdown â HTML with git-referenced snippets. |
| 2 | import MarkdownIt from 'markdown-it'; |
| 3 | import os from 'node:os'; |
| 4 | import { parseRef, encodeFragment } from './refs.js'; |
| 5 | import { parseOptions, resolveRef } from './resolve.js'; |
| 6 | import { highlightLines, langForFence } from './highlight.js'; |
| 7 | import { rowMatches } from './rows.js'; |
| 8 | import { resolveStat } from './stats.js'; |
| 9 | |
| 10 | const md = new MarkdownIt({ html: false, linkify: true, typographer: false }); |
| 11 | |
| 12 | export function esc(s) { |
| 13 | return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); |
| 14 | } |
| 15 | |
| 16 | export function isRefInfo(info) { |
| 17 | const first = (info || '').trim().split(/\s+/)[0]; |
| 18 | return first === 'show' || first === 'diff' || first === '-C'; |
| 19 | } |
| 20 | |
| 21 | function 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. |
| 31 | const HINT_RE = /^đ\s*\**Code Walk/; |
| 32 | |
| 33 | function 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 | */ |
| 48 | export 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 | |
| 98 | md.renderer.rules.table_open = () => '<table class="cw-table">\n'; |
| 99 | |
| 100 | md.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 | |
| 112 | let renderCtx = { walk: null }; |
| 113 | |
| 114 | export function renderWalkHtml(prepared, walk) { |
| 115 | renderCtx = { walk }; |
| 116 | return md.renderer.render(prepared.tokens, md.options, {}); |
| 117 | } |
| 118 | |
| 119 | function 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 | |
| 131 | function 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 | |
| 141 | function 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 | |
| 147 | function 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 | |
| 157 | const STATUS = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: null, T: 'type changed' }; |
| 158 | |
| 159 | function 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 | |
| 195 | const 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>'; |
| 196 | const 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 | |
| 198 | function 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 | |
| 206 | function 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 | |
| 235 | const 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 | |
| 237 | const 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 | |
| 239 | function 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 | |
| 251 | function 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 | |
| 280 | export function tildify(p) { |
| 281 | const home = os.homedir(); |
| 282 | return p && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p; |
| 283 | } |
| 284 | |
| 285 | export 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 | |
| 321 | const commentMd = new MarkdownIt({ html: false, linkify: true, breaks: true }); |
| 322 | export function renderCommentBody(text) { |
| 323 | return commentMd.render(String(text || '')); |
| 324 | } |
| 325 | |
| 326 | // ---- --stat summaries ----------------------------------------------------------- |
| 327 | |
| 328 | const STATUS_LETTER = { A: 'A', D: 'D', R: 'R', C: 'C', M: 'M', T: 'T' }; |
| 329 | const STATUS_WORD = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: 'modified', T: 'type changed' }; |
| 330 | |
| 331 | /** GitHub-style five-square change bar. */ |
| 332 | function 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 | |
| 343 | function counts(t) { |
| 344 | return `<span class="cw-stat"><span class="add">+${t.added}</span><span class="del">â${t.removed}</span></span>`; |
| 345 | } |
| 346 | |
| 347 | const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; |
| 348 | |
| 349 | function 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.
| 1 | // Markdown â HTML with git-referenced snippets. |
| 2 | import MarkdownIt from 'markdown-it'; |
| 3 | import os from 'node:os'; |
| 4 | import { parseRef, encodeFragment } from './refs.js'; |
| 5 | import { parseOptions, resolveRef } from './resolve.js'; |
| 6 | import { highlightLines, langForFence } from './highlight.js'; |
| 7 | import { rowMatches } from './rows.js'; |
| 8 | import { resolveStat } from './stats.js'; |
| 9 | |
| 10 | const md = new MarkdownIt({ html: false, linkify: true, typographer: false }); |
| 11 | |
| 12 | export function esc(s) { |
| 13 | return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); |
| 14 | } |
| 15 | |
| 16 | export function isRefInfo(info) { |
| 17 | const first = (info || '').trim().split(/\s+/)[0]; |
| 18 | return first === 'show' || first === 'diff' || first === '-C'; |
| 19 | } |
| 20 | |
| 21 | function 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. |
| 31 | const HINT_RE = /^đ\s*\**Code Walk/; |
| 32 | |
| 33 | function 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 | */ |
| 48 | export 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 | |
| 98 | md.renderer.rules.table_open = () => '<table class="cw-table">\n'; |
| 99 | |
| 100 | md.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 | |
| 112 | let renderCtx = { walk: null }; |
| 113 | |
| 114 | export function renderWalkHtml(prepared, walk) { |
| 115 | renderCtx = { walk }; |
| 116 | return md.renderer.render(prepared.tokens, md.options, {}); |
| 117 | } |
| 118 | |
| 119 | function 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 | |
| 131 | function 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 | |
| 141 | function 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 | |
| 147 | function 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 | |
| 157 | const STATUS = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: null, T: 'type changed' }; |
| 158 | |
| 159 | function 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 | |
| 195 | const 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>'; |
| 196 | const 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 | |
| 198 | function 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 | |
| 206 | function 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 | |
| 235 | const 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 | |
| 237 | const 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 | |
| 239 | function 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 | |
| 251 | function 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 | |
| 280 | export function tildify(p) { |
| 281 | const home = os.homedir(); |
| 282 | return p && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p; |
| 283 | } |
| 284 | |
| 285 | export 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 | |
| 321 | const commentMd = new MarkdownIt({ html: false, linkify: true, breaks: true }); |
| 322 | export function renderCommentBody(text) { |
| 323 | return commentMd.render(String(text || '')); |
| 324 | } |
| 325 | |
| 326 | // ---- --stat summaries ----------------------------------------------------------- |
| 327 | |
| 328 | const STATUS_LETTER = { A: 'A', D: 'D', R: 'R', C: 'C', M: 'M', T: 'T' }; |
| 329 | const STATUS_WORD = { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', M: 'modified', T: 'type changed' }; |
| 330 | |
| 331 | /** GitHub-style five-square change bar. */ |
| 332 | function 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 | |
| 343 | function counts(t) { |
| 344 | return `<span class="cw-stat"><span class="add">+${t.added}</span><span class="del">â${t.removed}</span></span>`; |
| 345 | } |
| 346 | |
| 347 | const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; |
| 348 | |
| 349 | function 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:
| 1 | 1 | :root { | |
| 2 | 2 | --bg: #ffffff; | |
| 3 | 3 | --fg: #1f2328; | |
| 4 | 4 | --fg-muted: #59636e; | |
| 5 | 5 | --fg-subtle: #818b98; | |
| 6 | 6 | --border: #d1d9e0; | |
| 7 | 7 | --border-muted: #e4e8ec; | |
| 8 | 8 | --surface: #f6f8fa; | |
| 9 | 9 | --surface-2: #eff2f5; | |
| 10 | 10 | --link: #0969da; | |
| 11 | 11 | --accent: #8250df; | |
| 12 | 12 | --code-bg: #ffffff; | |
| 13 | 13 | --ln: #8c959f; | |
| 14 | 14 | --add-bg: #e6ffec; | |
| 15 | 15 | --add-ln-bg: #ccffd8; | |
| 16 | 16 | --add-mark: #abf2bc; | |
| 17 | 17 | --add-fg: #1a7f37; | |
| 18 | 18 | --del-bg: #ffebe9; | |
| 19 | 19 | --del-ln-bg: #ffd7d5; | |
| 20 | 20 | --del-mark: #ffc1bd; | |
| 21 | 21 | --del-fg: #cf222e; | |
| 22 | 22 | --hl-bg: #fff8c5; | |
| 23 | 23 | --hl-bar: #d4a72c; | |
| 24 | 24 | --sel-bg: rgba(130, 80, 223, 0.10); | |
| 25 | 25 | --sel-bar: #8250df; | |
| 26 | 26 | --fold-bg: #ddf4ff; | |
| 27 | 27 | --fold-fg: #0969da; | |
| 28 | 28 | --commit: #1f2328; | |
| 29 | 29 | --index: #9a6700; | |
| 30 | 30 | --index-bg: #fff8c5; | |
| 31 | 31 | --worktree: #0969da; | |
| 32 | 32 | --worktree-bg: #ddf4ff; | |
| 33 | 33 | --error-bg: #ffebe9; | |
| 34 | 34 | --error-border: #ff8182; | |
| 35 | 35 | --shadow: 0 1px 2px rgba(31, 35, 40, 0.06), 0 1px 3px rgba(31, 35, 40, 0.04); | |
| 36 | 36 | --radius: 10px; | |
| 37 | 37 | --prose-w: 760px; | |
| 38 | 38 | --block-w: min(1000px, calc(100vw - 32px)); | |
| 39 | 39 | --font: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif; | |
| 40 | 40 | --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; | |
| 41 | 41 | color-scheme: light; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | @media (prefers-color-scheme: dark) { | |
| 45 | 45 | :root { | |
| 46 | 46 | --bg: #0d1117; | |
| 47 | 47 | --fg: #e6edf3; | |
| 48 | 48 | --fg-muted: #9198a1; | |
| 49 | 49 | --fg-subtle: #6e7681; | |
| 50 | 50 | --border: #30363d; | |
| 51 | 51 | --border-muted: #21262d; | |
| 52 | 52 | --surface: #151b23; | |
| 53 | 53 | --surface-2: #1c232c; | |
| 54 | 54 | --link: #4493f8; | |
| 55 | 55 | --accent: #ab7df8; | |
| 56 | 56 | --code-bg: #0d1117; | |
| 57 | 57 | --ln: #6e7681; | |
| 58 | 58 | --add-bg: rgba(46, 160, 67, 0.15); | |
| 59 | 59 | --add-ln-bg: rgba(46, 160, 67, 0.25); | |
| 60 | 60 | --add-mark: rgba(46, 160, 67, 0.40); | |
| 61 | 61 | --add-fg: #3fb950; | |
| 62 | 62 | --del-bg: rgba(248, 81, 73, 0.10); | |
| 63 | 63 | --del-ln-bg: rgba(248, 81, 73, 0.22); | |
| 64 | 64 | --del-mark: rgba(248, 81, 73, 0.40); | |
| 65 | 65 | --del-fg: #f85149; | |
| 66 | 66 | --hl-bg: rgba(187, 128, 9, 0.18); | |
| 67 | 67 | --hl-bar: #d29922; | |
| 68 | 68 | --sel-bg: rgba(171, 125, 248, 0.14); | |
| 69 | 69 | --sel-bar: #ab7df8; | |
| 70 | 70 | --fold-bg: rgba(56, 139, 253, 0.12); | |
| 71 | 71 | --fold-fg: #4493f8; | |
| 72 | 72 | --commit: #e6edf3; | |
| 73 | 73 | --index: #d29922; | |
| 74 | 74 | --index-bg: rgba(187, 128, 9, 0.15); | |
| 75 | 75 | --worktree: #4493f8; | |
| 76 | 76 | --worktree-bg: rgba(56, 139, 253, 0.15); | |
| 77 | 77 | --error-bg: rgba(248, 81, 73, 0.10); | |
| 78 | 78 | --error-border: rgba(248, 81, 73, 0.5); | |
| 79 | 79 | --shadow: none; | |
| 80 | 80 | color-scheme: dark; | |
| 81 | 81 | } | |
| 82 | 82 | .cw-code .code span[style], .cw-pre span[style] { color: var(--shiki-dark) !important; } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 85 | * { box-sizing: border-box; } | |
| 86 | 86 | ||
| 87 | 87 | html { -webkit-text-size-adjust: 100%; scroll-padding-top: 24px; } | |
| 88 | 88 | ||
| 89 | 89 | body { | |
| 90 | 90 | margin: 0; | |
| 91 | 91 | background: var(--bg); | |
| 92 | 92 | color: var(--fg); | |
| 93 | 93 | font: 16px/1.65 var(--font); | |
| 94 | 94 | -webkit-font-smoothing: antialiased; | |
| 95 | 95 | text-rendering: optimizeLegibility; | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | /* ---- layout ---------------------------------------------------------------- */ | |
| 99 | 99 | ||
| 100 | 100 | .cw-layout { display: block; } | |
| 101 | 101 | .cw-main { max-width: var(--prose-w); margin: 0 auto; padding: 56px 16px 120px; } | |
| 102 | 102 | ||
| 103 | 103 | .cw-toc { display: none; } | |
| 104 | 104 | ||
| 105 | 105 | @media (min-width: 1280px) { | |
| 106 | 106 | :root { --block-w: min(1000px, calc(100vw - 600px)); } | |
| 107 | 107 | .cw-toc { | |
| 108 | 108 | display: block; | |
| 109 | 109 | position: fixed; | |
| 110 | 110 | top: 0; | |
| 111 | 111 | left: 0; | |
| 112 | 112 | width: 260px; | |
| 113 | 113 | height: 100vh; | |
| 114 | 114 | overflow-y: auto; | |
| 115 | 115 | padding: 64px 12px 32px 28px; | |
| 116 | 116 | } | |
| 117 | 117 | .cw-toc-inner { display: flex; flex-direction: column; gap: 1px; border-left: 1px solid var(--border-muted); } | |
| 118 | 118 | .cw-toc a { | |
| 119 | 119 | display: block; | |
| 120 | 120 | padding: 4px 10px; | |
| 121 | 121 | margin-left: -1px; | |
| 122 | 122 | border-left: 2px solid transparent; | |
| 123 | 123 | color: var(--fg-muted); | |
| 124 | 124 | text-decoration: none; | |
| 125 | 125 | font-size: 13px; | |
| 126 | 126 | line-height: 1.4; | |
| 127 | 127 | transition: color .15s, border-color .15s; | |
| 128 | 128 | } | |
| 129 | 129 | .cw-toc a.home { color: var(--fg); font-weight: 600; } | |
| 130 | 130 | .cw-toc a.l3 { padding-left: 22px; font-size: 12.5px; } | |
| 131 | 131 | .cw-toc a:hover { color: var(--fg); } | |
| 132 | 132 | .cw-toc a.active { color: var(--fg); border-left-color: var(--accent); font-weight: 500; } | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | @media (min-width: 1500px) { | |
| 136 | 136 | :root { --block-w: min(1080px, calc(100vw - 560px)); } | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | 139 | /* ---- prose ------------------------------------------------------------------ */ | |
| 140 | 140 | ||
| 141 | 141 | .cw-header { margin-bottom: 36px; } | |
| 142 | 142 | .cw-header h1, .cw-article h1 { | |
| 143 | 143 | font-size: 34px; | |
| 144 | 144 | line-height: 1.2; | |
| 145 | 145 | letter-spacing: -0.02em; | |
| 146 | 146 | font-weight: 700; | |
| 147 | 147 | margin: 0 0 12px; | |
| 148 | 148 | text-wrap: balance; | |
| 149 | 149 | } | |
| 150 | 150 | .cw-walkmeta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--fg-subtle); font-size: 13px; } | |
| 151 | 151 | .cw-walkmeta code { font-family: var(--mono); font-size: 12px; } | |
| 152 | 152 | .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); } | |
| 153 | 153 | ||
| 154 | 154 | .cw-article > :first-child { margin-top: 0; } | |
| 155 | 155 | .cw-article h2 { | |
| 156 | 156 | font-size: 24px; | |
| 157 | 157 | line-height: 1.3; | |
| 158 | 158 | letter-spacing: -0.015em; | |
| 159 | 159 | margin: 56px 0 14px; | |
| 160 | 160 | font-weight: 650; | |
| 161 | 161 | text-wrap: balance; | |
| 162 | 162 | } | |
| 163 | 163 | .cw-article h3 { font-size: 19px; line-height: 1.35; margin: 36px 0 10px; font-weight: 600; } | |
| 164 | 164 | .cw-article h4 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; } | |
| 165 | 165 | .cw-article h2, .cw-article h3 { scroll-margin-top: 24px; } | |
| 166 | 166 | .cw-article p, .cw-article ul, .cw-article ol, .cw-article blockquote, .cw-table { margin: 0 0 16px; } | |
| 167 | 167 | .cw-article ul, .cw-article ol { padding-left: 1.5em; } | |
| 168 | 168 | .cw-article li + li { margin-top: 4px; } | |
| 169 | 169 | .cw-article a { color: var(--link); text-decoration: none; } | |
| 170 | 170 | .cw-article a:hover { text-decoration: underline; } | |
| 171 | 171 | .cw-article hr { border: 0; border-top: 1px solid var(--border-muted); margin: 40px 0; } | |
| 172 | 172 | .cw-article blockquote { margin-left: 0; padding: 2px 16px; border-left: 3px solid var(--border); color: var(--fg-muted); } | |
| 173 | 173 | .cw-article :not(pre) > code { | |
| 174 | 174 | font-family: var(--mono); | |
| 175 | 175 | font-size: 0.86em; | |
| 176 | 176 | padding: 0.15em 0.4em; | |
| 177 | 177 | background: var(--surface-2); | |
| 178 | 178 | border-radius: 6px; | |
| 179 | 179 | } | |
| 180 | 180 | table.cw-table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 15px; } | |
| 181 | 181 | .cw-table th, .cw-table td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; } | |
| 182 | 182 | .cw-table th { background: var(--surface); font-weight: 600; } | |
| 183 | 183 | .cw-article img { max-width: 100%; } | |
| 184 | 184 | ||
| 185 | 185 | .cw-pre { | |
| 186 | 186 | font: 13px/1.6 var(--mono); | |
| 187 | 187 | background: var(--surface); | |
| 188 | 188 | border: 1px solid var(--border-muted); | |
| 189 | 189 | border-radius: var(--radius); | |
| 190 | 190 | padding: 14px 16px; | |
| 191 | 191 | overflow-x: auto; | |
| 192 | 192 | margin: 0 0 20px; | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | 195 | .cw-index { list-style: none; padding: 0; } | |
| 196 | 196 | .cw-index li { padding: 10px 0; border-bottom: 1px solid var(--border-muted); font-family: var(--mono); font-size: 14px; } | |
| 197 | 197 | ||
| 198 | 198 | /* ---- blocks that widen past the prose column ------------------------------- */ | |
| 199 | 199 | ||
| 200 | 200 | .cw-block, .cw-mermaid, .cw-error { | |
| 201 | 201 | width: var(--block-w); | |
| 202 | 202 | margin: 24px 0 28px calc(50% - var(--block-w) / 2); | |
| 203 | 203 | } | |
| 204 | 204 | @media (min-width: 1280px) { | |
| 205 | 205 | .cw-block, .cw-mermaid, .cw-error { | |
| 206 | 206 | /* Keep wide blocks clear of the fixed TOC: shift right when needed. */ | |
| 207 | 207 | margin-left: max(calc(50% - var(--block-w) / 2), calc(284px - (100vw - var(--prose-w)) / 2)); | |
| 208 | 208 | } | |
| 209 | 209 | } | |
| 210 | 210 | ||
| 211 | 211 | .cw-mermaid { overflow-x: auto; } | |
| 212 | 212 | .cw-mermaid pre.mermaid { margin: 0; width: 100%; text-align: center; background: none; font-family: var(--font); color: transparent; } | |
| 213 | 213 | .cw-mermaid pre.mermaid[data-processed] { color: inherit; } | |
| 214 | 214 | .cw-mermaid svg { max-width: 100%; height: auto; } | |
| 215 | 215 | ||
| 216 | 216 | /* ---- snippet --------------------------------------------------------------- */ | |
| 217 | 217 | ||
| 218 | 218 | .cw-snippet { | |
| 219 | 219 | margin: 0; | |
| 220 | 220 | border: 1px solid var(--border); | |
| 221 | 221 | border-radius: var(--radius); | |
| 222 | 222 | background: var(--code-bg); | |
| 223 | 223 | box-shadow: var(--shadow); | |
| 224 | 224 | overflow: hidden; | |
| 225 | 225 | } | |
| 226 | 226 | .cw-snippet + .cw-snippet { margin-top: 16px; } | |
| 227 | 227 | ||
| 228 | 228 | .cw-head { background: var(--surface); border-bottom: 1px solid var(--border-muted); padding: 9px 12px 8px 14px; } | |
| 229 | 229 | .cw-head-row { display: flex; align-items: center; gap: 8px; min-width: 0; } | |
| 230 | 230 | .cw-title { font-size: 13px; } | |
| 231 | 231 | .cw-meta { margin-top: 6px; justify-content: space-between; gap: 16px; flex-wrap: wrap; row-gap: 6px; } | |
| 232 | 232 | ||
| 233 | 233 | .cw-repo { | |
| 234 | 234 | font-family: var(--mono); | |
| 235 | 235 | font-size: 12px; | |
| 236 | 236 | color: var(--fg-muted); | |
| 237 | 237 | padding-right: 8px; | |
| 238 | 238 | border-right: 1px solid var(--border); | |
| 239 | 239 | white-space: nowrap; | |
| 240 | 240 | } | |
| 241 | 241 | .cw-path { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--fg); overflow-wrap: anywhere; } | |
| 242 | 242 | ||
| 243 | 243 | .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; } | |
| 244 | 244 | .cw-status code { font-family: var(--mono); font-size: 11px; } | |
| 245 | 245 | .cw-status.added { color: var(--add-fg); background: var(--add-bg); } | |
| 246 | 246 | .cw-status.deleted { color: var(--del-fg); background: var(--del-bg); } | |
| 247 | 247 | .cw-stat { font-family: var(--mono); font-size: 12px; display: inline-flex; gap: 6px; } | |
| 248 | 248 | .cw-stat .add { color: var(--add-fg); } | |
| 249 | 249 | .cw-stat .del { color: var(--del-fg); } | |
| 250 | 250 | ||
| 251 | 251 | .cw-link-btn, .cw-copy-cmd { | |
| 252 | 252 | appearance: none; | |
| 253 | 253 | border: 0; | |
| 254 | 254 | background: none; | |
| 255 | 255 | color: var(--fg-subtle); | |
| 256 | 256 | padding: 4px; | |
| 257 | 257 | border-radius: 6px; | |
| 258 | 258 | cursor: pointer; | |
| 259 | 259 | display: inline-flex; | |
| 260 | 260 | align-items: center; | |
| 261 | 261 | transition: color .15s, background .15s, opacity .15s; | |
| 262 | 262 | } | |
| 263 | 263 | .cw-link-btn { margin-left: auto; opacity: 0.55; } | |
| 264 | 264 | .cw-snippet:hover .cw-link-btn, .cw-link-btn:focus-visible { opacity: 1; } | |
| 265 | 265 | .cw-link-btn:hover, .cw-copy-cmd:hover { color: var(--fg); background: var(--surface-2); } | |
| 266 | 266 | ||
| 267 | 267 | .cw-sides { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; max-width: 100%; } | |
| 268 | 268 | .cw-arrow { color: var(--fg-subtle); font-size: 13px; } | |
| 269 | 269 | ||
| 270 | 270 | .cw-side { | |
| 271 | 271 | display: inline-flex; | |
| 272 | 272 | align-items: center; | |
| 273 | 273 | gap: 6px; | |
| 274 | 274 | height: 22px; | |
| 275 | 275 | padding: 0 8px; | |
| 276 | 276 | border-radius: 6px; | |
| 277 | 277 | border: 1px solid var(--border); | |
| 278 | 278 | background: var(--bg); | |
| 279 | 279 | font-size: 11.5px; | |
| 280 | 280 | font-weight: 600; | |
| 281 | 281 | letter-spacing: 0.02em; | |
| 282 | 282 | white-space: nowrap; | |
| 283 | 283 | cursor: default; | |
| 284 | 284 | max-width: 100%; | |
| 285 | 285 | min-width: 0; | |
| 286 | 286 | } | |
| 287 | 287 | .cw-side code { font-family: var(--mono); font-size: 12px; font-weight: 500; letter-spacing: 0; } | |
| 288 | 288 | .cw-side i { width: 8px; height: 8px; border-radius: 50%; flex: none; } | |
| 289 | 289 | .cw-side.commit { color: var(--commit); } | |
| 290 | 290 | .cw-side.commit i { background: var(--commit); } | |
| 291 | 291 | .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); } | |
| 292 | 292 | .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; } | |
| 293 | 293 | .cw-side.index { color: var(--index); background: var(--index-bg); border-color: transparent; } | |
| 294 | 294 | .cw-side.index i { background: linear-gradient(90deg, var(--index) 50%, transparent 50%); border: 1.5px solid var(--index); } | |
| 295 | 295 | .cw-side.worktree { color: var(--worktree); background: var(--worktree-bg); border-color: transparent; } | |
| 296 | 296 | .cw-side.worktree i { border: 1.5px solid var(--worktree); } | |
| 297 | 297 | ||
| 298 | 298 | .cw-cmd { display: inline-flex; align-items: center; gap: 2px; min-width: 0; margin-left: auto; } | |
| 299 | 299 | .cw-cmd code { | |
| 300 | 300 | font-family: var(--mono); | |
| 301 | 301 | font-size: 11.5px; | |
| 302 | 302 | color: var(--fg-subtle); | |
| 303 | 303 | white-space: nowrap; | |
| 304 | 304 | overflow: hidden; | |
| 305 | 305 | text-overflow: ellipsis; | |
| 306 | 306 | max-width: 60ch; | |
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | 309 | .cw-note { padding: 14px 16px; color: var(--fg-muted); font-size: 14px; font-style: italic; } | |
| 310 | 310 | ||
| 311 | 311 | /* ---- code table ------------------------------------------------------------ */ | |
| 312 | 312 | ||
| 313 | 313 | .cw-scroll { overflow-x: auto; } | |
| 314 | 314 | .cw-code { | |
| 315 | 315 | border-collapse: collapse; | |
| 316 | 316 | width: 100%; | |
| 317 | 317 | font: 12.75px/20px var(--mono); | |
| 318 | 318 | font-variant-ligatures: none; | |
| 319 | 319 | tab-size: 4; | |
| 320 | 320 | } | |
| 321 | 321 | .cw-code td { padding: 0; vertical-align: top; } | |
| 322 | 322 | .cw-code .ln { | |
| 323 | 323 | width: 1%; | |
| 324 | 324 | min-width: 44px; | |
| 325 | 325 | padding: 0 10px 0 12px; | |
| 326 | 326 | text-align: right; | |
| 327 | 327 | color: var(--ln); | |
| 328 | 328 | user-select: none; | |
| 329 | 329 | -webkit-user-select: none; | |
| 330 | 330 | cursor: pointer; | |
| 331 | 331 | white-space: nowrap; | |
| 332 | 332 | } | |
| 333 | 333 | .cw-code .ln:hover { color: var(--fg); } | |
| 334 | 334 | .cw-code.diff .ln[data-side="R"] { padding-left: 4px; } | |
| 335 | 335 | .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; } | |
| 336 | 336 | .cw-code .code { white-space: pre; padding: 0 16px 0 6px; } | |
| 337 | 337 | .cw-code.file .code { padding-left: 10px; } | |
| 338 | 338 | .cw-code tbody:first-child tr.r:first-child td, .cw-code tbody:first-child .cw-expander td { padding-top: 6px; } | |
| 339 | 339 | .cw-code tbody:last-child tr.r:last-child td { padding-bottom: 6px; } | |
| 340 | 340 | ||
| 341 | 341 | .cw-code tr.add .code, .cw-code tr.add .mk { background: var(--add-bg); } | |
| 342 | 342 | .cw-code tr.add .ln { background: var(--add-ln-bg); } | |
| 343 | 343 | .cw-code tr.add .mk { color: var(--add-fg); } | |
| 344 | 344 | .cw-code tr.del .code, .cw-code tr.del .mk { background: var(--del-bg); } | |
| 345 | 345 | .cw-code tr.del .ln { background: var(--del-ln-bg); } | |
| 346 | 346 | .cw-code tr.del .mk { color: var(--del-fg); } | |
| 347 | 347 | .cw-code tr.add mark { background: var(--add-mark); color: inherit; border-radius: 3px; } | |
| 348 | 348 | .cw-code tr.del mark { background: var(--del-mark); color: inherit; border-radius: 3px; } | |
| 349 | 349 | .cw-code mark { padding: 1px 0; } | |
| 350 | 350 | ||
| 351 | 351 | .cw-code tr.hl .code { background-image: linear-gradient(var(--hl-bg), var(--hl-bg)); } | |
| 352 | 352 | .cw-code tr.hl .ln:first-child { box-shadow: inset 3px 0 0 var(--hl-bar); } | |
| 353 | 353 | ||
| 354 | 354 | .cw-code tr.sel td { background-image: linear-gradient(var(--sel-bg), var(--sel-bg)); } | |
| 355 | 355 | .cw-code tr.sel .ln:first-child { box-shadow: inset 3px 0 0 var(--sel-bar); } | |
| 356 | 356 | .cw-code tr.sel .ln { color: var(--fg); background-color: color-mix(in srgb, var(--sel-bar) 22%, var(--code-bg)); } | |
| 357 | 357 | .cw-code tr.flash td { animation: cw-flash 1.4s ease-out; } | |
| 358 | 358 | @keyframes cw-flash { | |
| 359 | 359 | 0%, 25% { background-color: color-mix(in srgb, var(--sel-bar) 26%, transparent); } | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | 362 | .cw-fold:not(.open) tr.r { display: none; } | |
| 363 | 363 | .cw-expander td { padding: 0 !important; } | |
| 364 | 364 | .cw-expander button > span { display: flex; align-items: center; gap: 8px; } | |
| 365 | 365 | .cw-fold:not(.open) .cw-less, .cw-fold.open .cw-more { display: none !important; } | |
| 366 | 366 | .cw-expander button { | |
| 367 | 367 | appearance: none; | |
| 368 | 368 | display: flex; | |
| 369 | 369 | align-items: center; | |
| 370 | 370 | gap: 8px; | |
| 371 | 371 | width: 100%; | |
| 372 | 372 | border: 0; | |
| 373 | 373 | background: var(--fold-bg); | |
| 374 | 374 | color: var(--fold-fg); | |
| 375 | 375 | font: 12px/26px var(--font); | |
| 376 | 376 | padding: 0 14px; | |
| 377 | 377 | cursor: pointer; | |
| 378 | 378 | text-align: left; | |
| 379 | 379 | transition: filter .15s; | |
| 380 | 380 | } | |
| 381 | 381 | .cw-expander button:hover { filter: brightness(0.96); text-decoration: underline; } | |
| 382 | 382 | @media (prefers-color-scheme: dark) { .cw-expander button:hover { filter: brightness(1.25); } } | |
| 383 | 383 | ||
| 384 | 384 | /* ---- commit card ----------------------------------------------------------- */ | |
| 385 | 385 | ||
| 386 | 386 | .cw-commit { | |
| 387 | 387 | border: 1px solid var(--border); | |
| 388 | 388 | border-radius: var(--radius); | |
| 389 | 389 | padding: 14px 16px; | |
| 390 | 390 | margin-bottom: 16px; | |
| 391 | 391 | background: var(--surface); | |
| 392 | 392 | } | |
| 393 | 393 | .cw-commit-subject { font-weight: 600; font-size: 15px; line-height: 1.4; } | |
| 394 | 394 | .cw-commit-body { white-space: pre-wrap; color: var(--fg-muted); font-size: 14px; margin-top: 6px; } | |
| 395 | 395 | .cw-commit-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 10px; font-size: 13px; color: var(--fg-muted); } | |
| 396 | 396 | ||
| 397 | 397 | /* ---- errors, toast ----------------------------------------------------------- */ | |
| 398 | 398 | ||
| 399 | 399 | .cw-error { | |
| 400 | 400 | border: 1px solid var(--error-border); | |
| 401 | 401 | background: var(--error-bg); | |
| 402 | 402 | border-radius: var(--radius); | |
| 403 | 403 | padding: 12px 16px; | |
| 404 | 404 | font-size: 14px; | |
| 405 | 405 | } | |
| 406 | 406 | .cw-error-title { font-weight: 600; margin-bottom: 4px; } | |
| 407 | 407 | .cw-error-title code { font-family: var(--mono); font-size: 12.5px; } | |
| 408 | 408 | .cw-error-msg { font-family: var(--mono); font-size: 12.5px; color: var(--del-fg); white-space: pre-wrap; } | |
| 409 | 409 | ||
| 410 | 410 | .cw-toast { | |
| 411 | 411 | position: fixed; | |
| 412 | 412 | left: 50%; | |
| 413 | 413 | top: 20px; | |
| 414 | 414 | z-index: 30; | |
| 415 | 415 | transform: translate(-50%, -12px); | |
| 416 | 416 | background: var(--fg); | |
| 417 | 417 | color: var(--bg); | |
| 418 | 418 | font-size: 13px; | |
| 419 | 419 | padding: 8px 14px; | |
| 420 | 420 | border-radius: 8px; | |
| 421 | 421 | opacity: 0; | |
| 422 | 422 | pointer-events: none; | |
| 423 | 423 | transition: opacity .18s, transform .18s; | |
| 424 | 424 | max-width: calc(100vw - 32px); | |
| 425 | 425 | white-space: nowrap; | |
| 426 | 426 | overflow: hidden; | |
| 427 | 427 | text-overflow: ellipsis; | |
| 428 | 428 | } | |
| 429 | 429 | .cw-toast.show { opacity: 1; transform: translate(-50%, 0); } | |
| 430 | 430 | ||
| 431 | 431 | /* ---- review: selection toolbar ------------------------------------------------- */ | |
| 432 | 432 | ||
| 433 | 433 | .cw-selbar { | |
| 434 | 434 | position: fixed; | |
| 435 | 435 | left: 50%; | |
| 436 | 436 | bottom: 20px; | |
| 437 | 437 | z-index: 20; | |
| 438 | 438 | display: flex; | |
| 439 | 439 | align-items: center; | |
| 440 | 440 | gap: 2px; | |
| 441 | 441 | padding: 4px; | |
| 442 | 442 | max-width: calc(100vw - 32px); | |
| 443 | 443 | background: var(--bg); | |
| 444 | 444 | border: 1px solid var(--border); | |
| 445 | 445 | border-radius: 10px; | |
| 446 | 446 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08); | |
| 447 | 447 | font-size: 13px; | |
| 448 | 448 | transform: translate(-50%, 12px); | |
| 449 | 449 | opacity: 0; | |
| 450 | 450 | pointer-events: none; | |
| 451 | 451 | transition: opacity .15s, transform .15s; | |
| 452 | 452 | } | |
| 453 | 453 | .cw-selbar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; } | |
| 454 | 454 | .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; } | |
| 455 | 455 | .cw-selbar-range code { color: var(--fg); } | |
| 456 | 456 | .cw-selbar button { | |
| 457 | 457 | appearance: none; | |
| 458 | 458 | border: 0; | |
| 459 | 459 | background: none; | |
| 460 | 460 | color: var(--fg); | |
| 461 | 461 | font: 500 13px/1 var(--font); | |
| 462 | 462 | padding: 8px 10px; | |
| 463 | 463 | border-radius: 7px; | |
| 464 | 464 | cursor: pointer; | |
| 465 | 465 | display: inline-flex; | |
| 466 | 466 | align-items: center; | |
| 467 | 467 | gap: 6px; | |
| 468 | 468 | white-space: nowrap; | |
| 469 | 469 | } | |
| 470 | 470 | .cw-selbar button:hover { background: var(--surface-2); } | |
| 471 | 471 | .cw-selbar button[data-act="clear"] { color: var(--fg-subtle); padding: 8px; } | |
| 472 | 472 | .cw-selbar button[data-act="comment"] { color: var(--accent); } | |
| 473 | 473 | kbd { | |
| 474 | 474 | font: 11px/1 var(--mono); | |
| 475 | 475 | padding: 2px 5px; | |
| 476 | 476 | border: 1px solid var(--border); | |
| 477 | 477 | border-bottom-width: 2px; | |
| 478 | 478 | border-radius: 4px; | |
| 479 | 479 | color: var(--fg-muted); | |
| 480 | 480 | background: var(--surface); | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | 483 | /* ---- review: threads and composer ------------------------------------------------ */ | |
| 484 | 484 | ||
| 485 | + | /* A solid bar down the gutter spanning the commented lines, capped at the range's ends. */ | |
| 485 | 486 | .cw-code tr.commented .ln:first-child::after { | |
| 486 | 487 | content: ""; | |
| 487 | 488 | 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; | |
| 493 | 493 | background: var(--accent); | |
| 494 | 494 | } | |
| 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; } | |
| 495 | 497 | .cw-code tr.commented.resolved-only .ln:first-child::after { background: var(--border); } | |
| 496 | 498 | .cw-code .ln:first-child { position: relative; } | |
| 497 | 499 | ||
| 498 | 500 | .cw-comment-row > td { padding: 0 !important; background: var(--surface); border-top: 1px solid var(--border-muted); border-bottom: 1px solid var(--border-muted); } | |
| 499 | 501 | .cw-threads { display: flex; flex-direction: column; gap: 10px; padding: 12px; } | |
| 500 | 502 | .cw-composer-row .cw-composer { margin: 12px; } | |
| 501 | 503 | ||
| 502 | 504 | .cw-thread, .cw-composer { | |
| 503 | 505 | position: sticky; | |
| 504 | 506 | left: 12px; | |
| 505 | 507 | width: min(720px, calc(var(--block-w) - 26px)); | |
| 506 | 508 | max-width: calc(100vw - 60px); | |
| 507 | 509 | font: 14px/1.55 var(--font); | |
| 508 | 510 | white-space: normal; | |
| 509 | 511 | background: var(--bg); | |
| 510 | 512 | border: 1px solid var(--border); | |
| 511 | 513 | border-radius: 8px; | |
| 512 | 514 | } | |
| 513 | 515 | .cw-thread { overflow: hidden; } | |
| 514 | 516 | .cw-thread.flash { animation: cw-thread-flash 1.4s ease-out; } | |
| 515 | 517 | @keyframes cw-thread-flash { | |
| 516 | 518 | 0%, 30% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); } | |
| 517 | 519 | 100% { box-shadow: 0 0 0 3px transparent; } | |
| 518 | 520 | } | |
| 519 | 521 | .cw-composer.inline { position: static; width: auto; max-width: none; margin: 4px 12px 12px 48px; } | |
| 520 | 522 | .cw-composer.inline.reply { margin-top: 10px; } | |
| 521 | 523 | .cw-composer { padding: 10px; } | |
| 522 | 524 | .cw-composer-head { font-size: 13px; color: var(--fg-muted); margin: 0 0 8px 2px; } | |
| 523 | 525 | .cw-composer textarea { | |
| 524 | 526 | display: block; | |
| 525 | 527 | width: 100%; | |
| 526 | 528 | min-height: 64px; | |
| 527 | 529 | resize: vertical; | |
| 528 | 530 | padding: 8px 10px; | |
| 529 | 531 | border: 1px solid var(--border); | |
| 530 | 532 | border-radius: 6px; | |
| 531 | 533 | background: var(--bg); | |
| 532 | 534 | color: var(--fg); | |
| 533 | 535 | font: 14px/1.5 var(--font); | |
| 534 | 536 | } | |
| 535 | 537 | .cw-composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } | |
| 536 | 538 | .cw-composer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 8px; } | |
| 537 | 539 | .cw-hint { margin-right: auto; font-size: 12px; color: var(--fg-subtle); } | |
| 538 | 540 | ||
| 539 | 541 | .cw-btn { | |
| 540 | 542 | appearance: none; | |
| 541 | 543 | border: 1px solid var(--border); | |
| 542 | 544 | background: var(--surface); | |
| 543 | 545 | color: var(--fg); | |
| 544 | 546 | font: 500 13px/1 var(--font); | |
| 545 | 547 | padding: 7px 12px; | |
| 546 | 548 | border-radius: 6px; | |
| 547 | 549 | cursor: pointer; | |
| 548 | 550 | } | |
| 549 | 551 | .cw-btn:hover { background: var(--surface-2); } | |
| 550 | 552 | .cw-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 551 | 553 | .cw-btn.primary:hover { filter: brightness(1.08); } | |
| 552 | 554 | .cw-btn:disabled { opacity: .6; cursor: default; } | |
| 553 | 555 | .cw-btn.ghost { border-color: transparent; background: none; color: var(--fg-muted); padding: 6px 8px; } | |
| 554 | 556 | .cw-btn.ghost:hover { color: var(--fg); background: var(--surface-2); } | |
| 555 | 557 | ||
| 556 | 558 | .cw-msg { display: flex; gap: 10px; padding: 12px 14px 4px; } | |
| 557 | 559 | .cw-msg.reply { padding-top: 8px; } | |
| 558 | 560 | .cw-msg + .cw-msg.reply { border-top: 1px solid var(--border-muted); margin-top: 6px; padding-top: 12px; } | |
| 559 | 561 | .cw-avatar { | |
| 560 | 562 | flex: none; | |
| 561 | 563 | width: 24px; | |
| 562 | 564 | height: 24px; | |
| 563 | 565 | border-radius: 50%; | |
| 564 | 566 | display: grid; | |
| 565 | 567 | place-items: center; | |
| 566 | 568 | font-size: 12px; | |
| 567 | 569 | font-weight: 600; | |
| 568 | 570 | color: var(--fg-muted); | |
| 569 | 571 | background: var(--surface-2); | |
| 570 | 572 | border: 1px solid var(--border); | |
| 571 | 573 | } | |
| 572 | 574 | .cw-avatar.claude { color: #fff; background: #d97757; border-color: #d97757; font-size: 13px; } | |
| 573 | 575 | .cw-msg-main { min-width: 0; flex: 1; } | |
| 574 | 576 | .cw-msg-meta { display: flex; align-items: baseline; gap: 8px; font-size: 13px; } | |
| 575 | 577 | .cw-msg-meta b { font-weight: 600; } | |
| 576 | 578 | .cw-msg-meta time, .cw-edited { color: var(--fg-subtle); font-size: 12px; } | |
| 577 | 579 | .cw-msg-menu { margin-left: auto; display: inline-flex; gap: 2px; opacity: 0; transition: opacity .15s; } | |
| 578 | 580 | .cw-thread:hover .cw-msg-menu, .cw-msg-menu:focus-within { opacity: 1; } | |
| 579 | 581 | .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; } | |
| 580 | 582 | .cw-msg-menu button:hover { color: var(--fg); background: var(--surface-2); } | |
| 581 | 583 | .cw-msg-body { margin-top: 2px; overflow-wrap: anywhere; } | |
| 582 | 584 | .cw-msg-body > :first-child { margin-top: 0; } | |
| 583 | 585 | .cw-msg-body > :last-child { margin-bottom: 0; } | |
| 584 | 586 | .cw-msg-body p, .cw-msg-body ul, .cw-msg-body ol, .cw-msg-body pre { margin: 0 0 8px; } | |
| 585 | 587 | .cw-msg-body ul, .cw-msg-body ol { padding-left: 1.3em; } | |
| 586 | 588 | .cw-msg-body code { font-family: var(--mono); font-size: 0.86em; padding: 0.1em 0.35em; background: var(--surface-2); border-radius: 4px; } | |
| 587 | 589 | .cw-msg-body pre { background: var(--surface); padding: 8px 10px; border-radius: 6px; overflow-x: auto; } | |
| 588 | 590 | .cw-msg-body pre code { padding: 0; background: none; } | |
| 589 | 591 | .cw-msg-body a { color: var(--link); } | |
| 590 | 592 | ||
| 591 | 593 | .cw-thread-actions { display: flex; gap: 2px; padding: 4px 10px 8px 44px; } | |
| 592 | 594 | .cw-thread-link { margin-left: auto; } | |
| 593 | 595 | ||
| 594 | 596 | .cw-thread.resolved { border-style: dashed; } | |
| 595 | 597 | .cw-thread-toggle { | |
| 596 | 598 | appearance: none; | |
| 597 | 599 | display: flex; | |
| 598 | 600 | align-items: center; | |
| 599 | 601 | gap: 6px; | |
| 600 | 602 | width: 100%; | |
| 601 | 603 | border: 0; | |
| 602 | 604 | background: none; | |
| 603 | 605 | padding: 9px 14px; | |
| 604 | 606 | font: 13px var(--font); | |
| 605 | 607 | color: var(--fg-muted); | |
| 606 | 608 | cursor: pointer; | |
| 607 | 609 | text-align: left; | |
| 608 | 610 | } | |
| 609 | 611 | .cw-thread:not(.collapsed) .cw-thread-toggle { border-bottom: 1px solid var(--border-muted); } | |
| 610 | 612 | .cw-thread-toggle:hover { color: var(--fg); } | |
| 611 | 613 | .cw-thread-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 612 | 614 | .cw-thread-count { margin-left: auto; font-size: 12px; color: var(--fg-subtle); white-space: nowrap; } | |
| 613 | 615 | .cw-check { color: var(--add-fg); font-weight: 700; } | |
| 614 | 616 | ||
| 615 | 617 | .cw-unplaced { margin-top: 64px; } | |
| 616 | 618 | .cw-unplaced h2 { font-size: 18px !important; color: var(--fg-muted); } | |
| 617 | 619 | .cw-unplaced .cw-thread { position: static; width: auto; max-width: none; margin-bottom: 12px; } | |
| 618 | 620 | .cw-quote { border-bottom: 1px solid var(--border-muted); background: var(--surface); } | |
| 619 | 621 | .cw-quote-head { padding: 8px 14px 0; font-size: 12px; color: var(--fg-muted); } | |
| 620 | 622 | .cw-quote-head code { font-family: var(--mono); } | |
| 621 | 623 | .cw-quote pre { margin: 0; padding: 6px 14px 10px; font: 12px/1.6 var(--mono); overflow-x: auto; } | |
| 622 | 624 | .cw-quote pre .add { color: var(--add-fg); } | |
| 623 | 625 | .cw-quote pre .del { color: var(--del-fg); } | |
| 624 | 626 | ||
| 625 | 627 | @media (max-width: 640px) { | |
| 626 | 628 | body { font-size: 15.5px; } | |
| 627 | 629 | .cw-main { padding-top: 32px; } | |
| 628 | 630 | .cw-header h1, .cw-article h1 { font-size: 27px; } | |
| 629 | 631 | .cw-article h2 { font-size: 21px; margin-top: 44px; } | |
| 630 | 632 | .cw-snippet { border-radius: 8px; } | |
| 631 | 633 | .cw-cmd code { max-width: 40ch; } | |
| 632 | 634 | } | |
| 633 | 635 | ||
| 634 | 636 | @media (prefers-reduced-motion: reduce) { | |
| 635 | 637 | * { transition: none !important; animation: none !important; } | |
| 636 | 638 | } | |
| 637 | 639 | ||
| 638 | 640 | /* Inline delete confirmation stays visible while pending. */ | |
| 639 | 641 | .cw-msg-menu.confirming { opacity: 1; align-items: baseline; } | |
| 640 | 642 | .cw-confirm-text { font-size: 12px; color: var(--fg-muted); margin-right: 2px; } | |
| 641 | 643 | .cw-msg-menu button.danger { color: var(--del-fg); font-weight: 600; } | |
| 642 | 644 | .cw-msg-menu button.danger:hover { background: var(--del-bg); color: var(--del-fg); } | |
| 643 | 645 | ||
| 644 | 646 | /* Editing a message: the textarea takes the place of the text, aligned with it and sized to fit. */ | |
| 645 | 647 | .cw-composer.inline.edit { margin: -2px 0 4px -11px; padding: 0; border: 0; background: none; } | |
| 646 | 648 | .cw-composer.inline.edit textarea { min-height: 0; padding: 5px 10px; resize: none; overflow: hidden; line-height: 1.55; } | |
| 647 | 649 | .cw-composer textarea { overflow-y: hidden; } | |
| 648 | 650 | .cw-composer.inline.edit .cw-composer-actions { margin-top: 6px; } | |
| 649 | 651 | .cw-thread-actions.editing { align-items: center; } | |
| 650 | 652 | .cw-thread-actions.editing .cw-hint { margin: 0 0 0 auto; padding-right: 4px; } | |
| 651 | 653 | .cw-btn.ghost.save { color: var(--accent); font-weight: 600; } | |
| 652 | 654 | .cw-btn.ghost.save:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } | |
| 653 | 655 | /* No Edit / Delete menus while a message in the thread is being edited. */ | |
| 654 | 656 | .cw-thread.editing .cw-msg-menu { display: none; } | |
| 655 | 657 | ||
| 656 | 658 | /* Edit conflict: the message changed after editing started. */ | |
| 657 | 659 | .cw-conflict { | |
| 658 | 660 | margin: 0 0 8px; | |
| 659 | 661 | padding: 10px 12px; | |
| 660 | 662 | border: 1px solid color-mix(in srgb, var(--hl-bar) 55%, transparent); | |
| 661 | 663 | background: var(--hl-bg); | |
| 662 | 664 | border-radius: 6px; | |
| 663 | 665 | font-size: 13px; | |
| 664 | 666 | } | |
| 665 | 667 | .cw-conflict-head { font-weight: 600; margin-bottom: 6px; } | |
| 666 | 668 | .cw-conflict-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-muted); margin-bottom: 2px; } | |
| 667 | 669 | .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); } | |
| 668 | 670 | .cw-conflict-actions { display: flex; gap: 2px; margin: 6px 0 0 -8px; } | |
| 669 | 671 | ||
| 670 | 672 | /* ---- --stat summaries ------------------------------------------------------------ */ | |
| 671 | 673 | ||
| 672 | 674 | .cw-summary { | |
| 673 | 675 | border: 1px solid var(--border); | |
| 674 | 676 | border-radius: var(--radius); | |
| 675 | 677 | background: var(--code-bg); | |
| 676 | 678 | box-shadow: var(--shadow); | |
| 677 | 679 | overflow: hidden; | |
| 678 | 680 | } | |
| 679 | 681 | .cw-sum-headline { font-weight: 600; font-size: 13px; } | |
| 680 | 682 | .cw-sum-subject { color: var(--fg-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 681 | 683 | .cw-bar { display: inline-flex; gap: 1px; vertical-align: middle; } | |
| 682 | 684 | .cw-bar i { width: 8px; height: 8px; border-radius: 1.5px; background: var(--border); } | |
| 683 | 685 | .cw-bar i.add { background: var(--add-fg); } | |
| 684 | 686 | .cw-bar i.del { background: var(--del-fg); } | |
| 685 | 687 | ||
| 686 | 688 | .cw-sum { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 687 | 689 | .cw-sum td { padding: 5px 10px; vertical-align: top; border-top: 1px solid var(--border-muted); } | |
| 688 | 690 | .cw-sum tbody:first-child tr:first-child td { border-top: 0; } | |
| 689 | 691 | .cw-sum-status { width: 1%; padding-left: 14px !important; padding-right: 2px !important; } | |
| 690 | 692 | .cw-sum-status span { | |
| 691 | 693 | display: inline-grid; | |
| 692 | 694 | place-items: center; | |
| 693 | 695 | width: 16px; | |
| 694 | 696 | height: 16px; | |
| 695 | 697 | border-radius: 4px; | |
| 696 | 698 | font: 600 10px/1 var(--mono); | |
| 697 | 699 | color: var(--fg-muted); | |
| 698 | 700 | background: var(--surface-2); | |
| 699 | 701 | } | |
| 700 | 702 | .cw-sum-status .s-A { color: var(--add-fg); background: var(--add-bg); } | |
| 701 | 703 | .cw-sum-status .s-D { color: var(--del-fg); background: var(--del-bg); } | |
| 702 | 704 | .cw-sum-status .s-R, .cw-sum-status .s-C { color: var(--worktree); background: var(--worktree-bg); } | |
| 703 | 705 | .cw-sum-path { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; } | |
| 704 | 706 | button.cw-sum-path { | |
| 705 | 707 | appearance: none; border: 0; background: none; padding: 0; color: var(--link); cursor: pointer; text-align: left; | |
| 706 | 708 | } | |
| 707 | 709 | button.cw-sum-path:hover { text-decoration: underline; } | |
| 708 | 710 | .cw-sum-rename { margin-left: 8px; color: var(--fg-subtle); font-size: 12px; } | |
| 709 | 711 | .cw-sum-rename code { font-family: var(--mono); } | |
| 710 | 712 | .cw-sum-note { color: var(--fg-muted); font-size: 12.5px; margin-top: 1px; } | |
| 711 | 713 | .cw-sum-counts { width: 1%; white-space: nowrap; text-align: right; } | |
| 712 | 714 | .cw-sum-counts .cw-stat { justify-content: flex-end; } | |
| 713 | 715 | .cw-sum-barcell { width: 1%; padding-right: 14px !important; white-space: nowrap; } | |
| 714 | 716 | .cw-sum-binary { color: var(--fg-subtle); font-size: 12px; } | |
| 715 | 717 | ||
| 716 | 718 | .cw-sum-grouphead td { background: var(--surface); padding-top: 8px; padding-bottom: 8px; border-top: 1px solid var(--border); } | |
| 717 | 719 | .cw-sum tbody:first-child .cw-sum-grouphead td { border-top: 0; } | |
| 718 | 720 | .cw-sum-grouphead td:first-child { padding-left: 14px; } | |
| 719 | 721 | .cw-sum-groupname { font-weight: 600; } | |
| 720 | 722 | .cw-sum-groupnote { color: var(--fg-muted); margin-left: 8px; } | |
| 721 | 723 | .cw-sum-groupcount { color: var(--fg-subtle); margin-left: 8px; font-size: 12px; } | |
| 722 | 724 | .cw-sum-group.implicit .cw-sum-groupname { color: var(--fg-muted); font-style: italic; } | |
| 723 | 725 | .cw-sum-total td { border-top: 1px solid var(--border); background: var(--surface); font-weight: 600; padding-top: 8px; padding-bottom: 8px; } | |
| 724 | 726 | .cw-sum-total td:first-child { padding-left: 14px; } | |
| 725 | 727 | .cw-sum-warnings { border-top: 1px solid var(--border-muted); padding: 8px 14px; font-size: 12.5px; color: var(--index); background: var(--index-bg); } | |
| 726 | 728 | .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:
| 1 | 1 | // Loading a walk file: frontmatter, repo configuration. | |
| 2 | 2 | import { readFile } from 'node:fs/promises'; | |
| 3 | 3 | import { existsSync, statSync } from 'node:fs'; | |
| 4 | 4 | import os from 'node:os'; | |
| 5 | 5 | import path from 'node:path'; | |
| 6 | 6 | import YAML from 'yaml'; | |
| 7 | 7 | import { repoRoot, GitError } from './git.js'; | |
| 8 | + | import { cacheDirFor, ensureRemote, isRemoteUrl, remoteName } from './remote.js'; | |
| 8 | 9 | ||
| 9 | 10 | export function splitFrontmatter(src) { | |
| 10 | 11 | const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src); | |
| 11 | 12 | if (!m) return { data: {}, body: src, bodyLineOffset: 0 }; | |
| 12 | 13 | let data = {}; | |
| 13 | 14 | try { | |
| 14 | 15 | data = YAML.parse(m[1]) || {}; | |
| 15 | 16 | } catch (e) { | |
| 16 | 17 | throw new Error(`invalid frontmatter: ${e.message}`); | |
| 17 | 18 | } | |
| 18 | 19 | return { data, body: src.slice(m[0].length), bodyLineOffset: m[0].split('\n').length - 1 }; | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | function expandHome(p) { | |
| 22 | 23 | return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 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 | + | ||
| 25 | 33 | /** | |
| 26 | 34 | * A walk context knows how to find repos. | |
| 27 | 35 | * { walkPath, dir, title, data, body, bodyLineOffset, repos: [{name, path}], defaultRepo, multiRepo } | |
| 28 | 36 | */ | |
| 29 | 37 | export async function loadWalk(walkPath, { fallbackDir = process.cwd() } = {}) { | |
| 30 | 38 | const src = await readFile(walkPath, 'utf8'); | |
| 31 | 39 | return walkFromSource(src, walkPath, { fallbackDir }); | |
| 32 | 40 | } | |
| 33 | 41 | ||
| 34 | 42 | export async function walkFromSource(src, walkPath, { fallbackDir = process.cwd() } = {}) { | |
| 35 | 43 | const { data, body, bodyLineOffset } = splitFrontmatter(src); | |
| 36 | 44 | const dir = walkPath ? path.dirname(path.resolve(walkPath)) : fallbackDir; | |
| 37 | 45 | 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); | |
| 39 | 48 | 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)); | |
| 43 | 50 | } | |
| 44 | 51 | if (!repos.length) { | |
| 45 | 52 | const root = (await repoRoot(dir)) || (await repoRoot(fallbackDir)); | |
| 46 | 53 | if (root) repos.push({ name: path.basename(root), configured: root, path: root }); | |
| 47 | 54 | } | |
| 48 | 55 | return { | |
| 49 | 56 | walkPath: walkPath ? path.resolve(walkPath) : null, | |
| 50 | 57 | dir, | |
| 51 | 58 | title: data.title ? String(data.title) : null, | |
| 52 | 59 | data, | |
| 53 | 60 | body, | |
| 54 | 61 | bodyLineOffset, | |
| 55 | 62 | repos, | |
| 56 | 63 | multiRepo: repos.length > 1, | |
| 57 | 64 | rootCache: new Map(), | |
| 58 | 65 | }; | |
| 59 | 66 | } | |
| 60 | 67 | ||
| 61 | 68 | /** Resolve a `-C` value (name or path) to {name, root}. */ | |
| 62 | 69 | export async function resolveRepo(walk, name) { | |
| 63 | 70 | let entry; | |
| 64 | 71 | if (name == null) { | |
| 65 | 72 | entry = walk.repos[0]; | |
| 66 | 73 | if (!entry) throw new GitError('no git repository found (add `repo:` or `repos:` to the frontmatter)'); | |
| 67 | 74 | } else { | |
| 68 | 75 | 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) { | |
| 70 | 79 | const p = path.resolve(walk.dir, expandHome(name)); | |
| 71 | 80 | if (!existsSync(p) || !statSync(p).isDirectory()) { | |
| 72 | 81 | const known = walk.repos.map((r) => r.name).join(', '); | |
| 73 | 82 | throw new GitError(`unknown repo "${name}"${known ? ` (known: ${known})` : ''}`); | |
| 74 | 83 | } | |
| 75 | 84 | entry = { name, configured: name, path: p, adhoc: true }; | |
| 76 | 85 | } | |
| 77 | 86 | } | |
| 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 | + | } | |
| 79 | 93 | 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; | |
| 82 | 103 | } | |
Commits
show <commit> renders the commit message, author and date above its diff. Add -- <path> to narrow it to some files:
| 1 | 1 | (() => { | |
| 2 | 2 | const CFG = window.CODE_WALK || {}; | |
| 3 | 3 | const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| 4 | 4 | const SEL_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/; | |
| 5 | 5 | ||
| 6 | 6 | // ---- toast ------------------------------------------------------------------ | |
| 7 | 7 | const toastEl = document.querySelector('.cw-toast'); | |
| 8 | 8 | let toastTimer; | |
| 9 | 9 | function toast(msg) { | |
| 10 | 10 | if (!toastEl) return; | |
| 11 | 11 | toastEl.textContent = msg; | |
| 12 | 12 | toastEl.classList.add('show'); | |
| 13 | 13 | clearTimeout(toastTimer); | |
| 14 | 14 | toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800); | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | async function copy(text, msg) { | |
| 18 | 18 | try { | |
| 19 | 19 | await navigator.clipboard.writeText(text); | |
| 20 | 20 | } catch { | |
| 21 | 21 | const ta = document.createElement('textarea'); | |
| 22 | 22 | ta.value = text; | |
| 23 | 23 | ta.style.position = 'fixed'; | |
| 24 | 24 | ta.style.opacity = '0'; | |
| 25 | 25 | document.body.appendChild(ta); | |
| 26 | 26 | ta.select(); | |
| 27 | 27 | document.execCommand('copy'); | |
| 28 | 28 | ta.remove(); | |
| 29 | 29 | } | |
| 30 | 30 | toast(msg); | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | // ---- live reload (keeps scroll position) ------------------------------------- | |
| 34 | 34 | const SCROLL_KEY = `cw-scroll:${location.pathname}`; | |
| 35 | 35 | try { | |
| 36 | 36 | const y = sessionStorage.getItem(SCROLL_KEY); | |
| 37 | 37 | if (y !== null) { | |
| 38 | 38 | sessionStorage.removeItem(SCROLL_KEY); | |
| 39 | 39 | history.scrollRestoration = 'manual'; | |
| 40 | 40 | window.__cwRestored = Number(y); | |
| 41 | 41 | } | |
| 42 | 42 | } catch { /* storage unavailable */ } | |
| 43 | 43 | ||
| 44 | 44 | if (window.EventSource) { | |
| 45 | 45 | const es = new EventSource('/events'); | |
| 46 | 46 | es.onmessage = (ev) => { | |
| 47 | 47 | let data = {}; | |
| 48 | 48 | try { data = JSON.parse(ev.data); } catch { return; } | |
| 49 | 49 | if (data.kind === 'comments') { | |
| 50 | 50 | if (data.walk === CFG.walk) loadComments(); | |
| 51 | 51 | return; | |
| 52 | 52 | } | |
| 53 | 53 | if (!CFG.walk || data.walk === CFG.walk) { | |
| 54 | 54 | try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch { /* ignore */ } | |
| 55 | 55 | location.reload(); | |
| 56 | 56 | } | |
| 57 | 57 | }; | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | // ---- mermaid ------------------------------------------------------------------- | |
| 61 | 61 | let layoutReady = Promise.resolve(); | |
| 62 | 62 | if (window.mermaid) { | |
| 63 | 63 | const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 64 | 64 | const css = getComputedStyle(document.documentElement); | |
| 65 | 65 | window.mermaid.initialize({ | |
| 66 | 66 | startOnLoad: false, | |
| 67 | 67 | securityLevel: 'strict', | |
| 68 | 68 | theme: dark ? 'dark' : 'neutral', | |
| 69 | 69 | fontFamily: css.getPropertyValue('--font').trim(), | |
| 70 | 70 | }); | |
| 71 | 71 | layoutReady = window.mermaid.run({ querySelector: 'pre.mermaid' }).catch((e) => console.error(e)); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // ---- folds ------------------------------------------------------------------------ | |
| 75 | 75 | function openFold(node) { | |
| 76 | 76 | const fold = node.closest('.cw-fold'); | |
| 77 | 77 | if (!fold) return; | |
| 78 | 78 | fold.classList.add('open'); | |
| 79 | 79 | fold.querySelector('.cw-expander button')?.setAttribute('aria-expanded', 'true'); | |
| 80 | 80 | } | |
| 81 | 81 | document.addEventListener('click', (e) => { | |
| 82 | 82 | const btn = e.target.closest('.cw-expander button'); | |
| 83 | 83 | if (!btn) return; | |
| 84 | 84 | const open = btn.closest('.cw-fold').classList.toggle('open'); | |
| 85 | 85 | btn.setAttribute('aria-expanded', String(open)); | |
| 86 | 86 | }); | |
| 87 | 87 | ||
| 88 | 88 | // ---- line selection --------------------------------------------------------------- | |
| 89 | 89 | let current = null; // { fig, anchor: {idx, side}, focus: {idx, side} } | |
| 90 | 90 | ||
| 91 | 91 | const rowsOf = (fig) => $$('tr.r', fig); | |
| 92 | 92 | ||
| 93 | 93 | function lineOf(row, side, mode) { | |
| 94 | 94 | const v = row.dataset[side === 'L' ? 'l' : 'r']; | |
| 95 | 95 | return v === undefined ? null : Number(v); | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | function pickSide(row, preferred, mode) { | |
| 99 | 99 | if (mode === 'file') return 'L'; | |
| 100 | 100 | if (lineOf(row, preferred) !== null) return preferred; | |
| 101 | 101 | return preferred === 'L' ? 'R' : 'L'; | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | function rangeFor(fig, a, b) { | |
| 105 | 105 | const rows = rowsOf(fig); | |
| 106 | 106 | const mode = fig.dataset.mode; | |
| 107 | 107 | let [s, e] = a.idx <= b.idx ? [a, b] : [b, a]; | |
| 108 | 108 | const sr = rows[s.idx]; | |
| 109 | 109 | const er = rows[e.idx]; | |
| 110 | 110 | if (mode === 'file') { | |
| 111 | 111 | const x = lineOf(sr, 'L'); | |
| 112 | 112 | const y = lineOf(er, 'L'); | |
| 113 | 113 | return x === y ? `L${x}` : `L${x}-${y}`; | |
| 114 | 114 | } | |
| 115 | 115 | const has = (row, side) => lineOf(row, side) !== null; | |
| 116 | 116 | let ss = s.side; | |
| 117 | 117 | let es = e.side; | |
| 118 | 118 | const valid = (p, q) => has(sr, p) && has(er, q) && !(p === 'R' && q === 'L'); | |
| 119 | 119 | if (!valid(ss, es)) { | |
| 120 | 120 | const options = [['L', 'L'], ['R', 'R'], ['L', 'R']]; | |
| 121 | 121 | const found = options.find(([p, q]) => valid(p, q)); | |
| 122 | 122 | if (found) [ss, es] = found; | |
| 123 | 123 | else { | |
| 124 | 124 | // Added lines followed by deleted lines: fall back to the new side. | |
| 125 | 125 | ss = 'R'; | |
| 126 | 126 | es = 'R'; | |
| 127 | 127 | let j = e.idx; | |
| 128 | 128 | while (j > s.idx && !has(rows[j], 'R')) j--; | |
| 129 | 129 | e = { idx: j }; | |
| 130 | 130 | } | |
| 131 | 131 | } | |
| 132 | 132 | const x = lineOf(sr, ss); | |
| 133 | 133 | const y = lineOf(rows[e.idx], es); | |
| 134 | 134 | if (ss === es) return x === y ? `${ss}${x}` : `${ss}${x}-${y}`; | |
| 135 | 135 | return `${ss}${x}-${es}${y}`; | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | 138 | /** Mirrors selectionSpan() on the server. Returns {start, end} (end exclusive) or null. */ | |
| 139 | 139 | function spanFor(fig, range) { | |
| 140 | 140 | const m = SEL_RE.exec(range); | |
| 141 | 141 | if (!m || m[5]) return null; | |
| 142 | 142 | const rows = rowsOf(fig); | |
| 143 | 143 | const mode = fig.dataset.mode; | |
| 144 | 144 | const s = { side: mode === 'file' ? 'L' : m[1], line: Number(m[2]) }; | |
| 145 | 145 | const e = m[4] ? { side: mode === 'file' ? 'L' : (m[3] || m[1]), line: Number(m[4]) } : { ...s }; | |
| 146 | 146 | let start = -1; | |
| 147 | 147 | let end = -1; | |
| 148 | 148 | if (s.side === e.side) { | |
| 149 | 149 | rows.forEach((r, i) => { | |
| 150 | 150 | const ln = lineOf(r, s.side); | |
| 151 | 151 | if (ln !== null && ln >= s.line && ln <= e.line) { | |
| 152 | 152 | if (start === -1) start = i; | |
| 153 | 153 | end = i; | |
| 154 | 154 | } | |
| 155 | 155 | }); | |
| 156 | 156 | } else { | |
| 157 | 157 | start = rows.findIndex((r) => lineOf(r, s.side) === s.line); | |
| 158 | 158 | for (let i = rows.length - 1; i >= 0; i--) if (lineOf(rows[i], e.side) === e.line) { end = i; break; } | |
| 159 | 159 | } | |
| 160 | 160 | if (start === -1 || end === -1 || end < start) return null; | |
| 161 | 161 | return { start, end: end + 1 }; | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | function clearSelection() { | |
| 165 | 165 | $$('tr.sel').forEach((r) => r.classList.remove('sel')); | |
| 166 | 166 | current = null; | |
| 167 | 167 | onSelectionChange(); | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | function paint(fig, span, { reveal = false, flash = false } = {}) { | |
| 171 | 171 | $$('tr.sel').forEach((r) => r.classList.remove('sel')); | |
| 172 | 172 | const rows = rowsOf(fig); | |
| 173 | 173 | for (let i = span.start; i < span.end; i++) { | |
| 174 | 174 | const r = rows[i]; | |
| 175 | 175 | r.classList.add('sel'); | |
| 176 | 176 | openFold(r); | |
| 177 | 177 | if (flash) { | |
| 178 | 178 | r.classList.remove('flash'); | |
| 179 | 179 | void r.offsetWidth; | |
| 180 | 180 | r.classList.add('flash'); | |
| 181 | 181 | } | |
| 182 | 182 | } | |
| 183 | 183 | onSelectionChange(); | |
| 184 | 184 | if (reveal) { | |
| 185 | 185 | const first = rows[span.start]; | |
| 186 | 186 | const rect = first.getBoundingClientRect(); | |
| 187 | 187 | const target = window.scrollY + rect.top - Math.max(80, window.innerHeight * 0.3); | |
| 188 | 188 | window.scrollTo({ top: Math.max(0, target), behavior: 'instant' in window ? 'instant' : 'auto' }); | |
| 189 | 189 | } | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | 192 | function snippetUrl(fig, range) { | |
| 193 | 193 | const frag = fig.dataset.frag + (range ? `&${range}` : ''); | |
| 194 | 194 | return `${location.origin}${location.pathname}#${frag}`; | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | function updateUrl(fig, range) { | |
| 198 | 198 | history.replaceState(null, '', `#${fig.dataset.frag}${range ? `&${range}` : ''}`); | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | 201 | document.addEventListener('click', (e) => { | |
| 202 | 202 | const cell = e.target.closest('.cw-code td.ln'); | |
| 203 | 203 | if (!cell) return; | |
| 204 | 204 | const fig = cell.closest('.cw-snippet'); | |
| 205 | 205 | const row = cell.closest('tr.r'); | |
| 206 | 206 | const rows = rowsOf(fig); | |
| 207 | 207 | const idx = rows.indexOf(row); | |
| 208 | 208 | const side = pickSide(row, cell.dataset.side, fig.dataset.mode); | |
| 209 | 209 | if (!current || current.fig !== fig || !e.shiftKey) { | |
| 210 | 210 | if (current && current.fig === fig && current.anchor.idx === idx && current.focus.idx === idx && !e.shiftKey) { | |
| 211 | 211 | clearSelection(); | |
| 212 | 212 | history.replaceState(null, '', location.pathname); | |
| 213 | 213 | return; | |
| 214 | 214 | } | |
| 215 | 215 | current = { fig, anchor: { idx, side }, focus: { idx, side } }; | |
| 216 | 216 | } else { | |
| 217 | 217 | current.focus = { idx, side }; | |
| 218 | 218 | window.getSelection()?.removeAllRanges(); | |
| 219 | 219 | } | |
| 220 | 220 | const range = rangeFor(fig, current.anchor, current.focus); | |
| 221 | 221 | const span = spanFor(fig, range); | |
| 222 | 222 | if (span) paint(fig, span); | |
| 223 | 223 | current.range = range; | |
| 224 | 224 | updateUrl(fig, range); | |
| 225 | 225 | onSelectionChange(); | |
| 226 | 226 | }); | |
| 227 | 227 | ||
| 228 | 228 | function decodeFragment(hash) { | |
| 229 | 229 | let frag = hash.replace(/^#/, ''); | |
| 230 | 230 | let range = null; | |
| 231 | 231 | const amp = frag.lastIndexOf('&'); | |
| 232 | 232 | if (amp !== -1 && SEL_RE.test(frag.slice(amp + 1))) { | |
| 233 | 233 | range = frag.slice(amp + 1); | |
| 234 | 234 | frag = frag.slice(0, amp); | |
| 235 | 235 | } | |
| 236 | 236 | let tokens; | |
| 237 | 237 | try { | |
| 238 | 238 | tokens = frag.split('+').filter(Boolean).map((t) => decodeURIComponent(t)); | |
| 239 | 239 | } catch { | |
| 240 | 240 | return null; | |
| 241 | 241 | } | |
| 242 | 242 | return { tokens, range }; | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | 245 | function findSnippet(link, range) { | |
| 246 | 246 | const strip = (s) => s.replace(/^-C \S+ /, ''); | |
| 247 | 247 | const figs = $$('.cw-snippet'); | |
| 248 | 248 | let matches = figs.filter((f) => f.dataset.link === link); | |
| 249 | 249 | if (!matches.length) matches = figs.filter((f) => strip(f.dataset.link) === strip(link)); | |
| 250 | 250 | // Prefer a snippet that already shows the lines without expanding folds. | |
| 251 | 251 | return matches.find((f) => { | |
| 252 | 252 | const span = range && spanFor(f, range); | |
| 253 | 253 | return span && rowsOf(f).slice(span.start, span.end).every((r) => !r.closest('.cw-fold')); | |
| 254 | 254 | }) || matches.find((f) => range && spanFor(f, range)) || (range ? null : matches[0]) || null; | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | 257 | function applyHash({ flash = true } = {}) { | |
| 258 | 258 | const cm = /^#comment-([0-9a-f]+)$/.exec(location.hash); | |
| 259 | 259 | if (cm) return commentsReady.then(() => focusThread(cm[1])); | |
| 260 | 260 | const hash = location.hash; | |
| 261 | 261 | if (!hash || document.getElementById(decodeURIComponent(hash.slice(1)))) return; | |
| 262 | 262 | const d = decodeFragment(hash); | |
| 263 | 263 | if (!d || !d.tokens.length || !['show', 'diff', '-C'].includes(d.tokens[0])) return; | |
| 264 | 264 | const fig = findSnippet(d.tokens.join(' '), d.range); | |
| 265 | 265 | if (!fig) { | |
| 266 | 266 | toast('That snippet isnât in this walk'); | |
| 267 | 267 | return; | |
| 268 | 268 | } | |
| 269 | 269 | if (!d.range) { | |
| 270 | 270 | fig.scrollIntoView({ block: 'start' }); | |
| 271 | 271 | return; | |
| 272 | 272 | } | |
| 273 | 273 | const span = spanFor(fig, d.range); | |
| 274 | 274 | if (!span) { | |
| 275 | 275 | toast(`Lines ${d.range} arenât shown in that snippet`); | |
| 276 | 276 | fig.scrollIntoView({ block: 'start' }); | |
| 277 | 277 | return; | |
| 278 | 278 | } | |
| 279 | 279 | const rows = rowsOf(fig); | |
| 280 | 280 | const sideOf = (r, pref) => pickSide(r, pref, fig.dataset.mode); | |
| 281 | 281 | const m = SEL_RE.exec(d.range); | |
| 282 | 282 | current = { | |
| 283 | 283 | fig, | |
| 284 | 284 | anchor: { idx: span.start, side: sideOf(rows[span.start], m[1]) }, | |
| 285 | 285 | focus: { idx: span.end - 1, side: sideOf(rows[span.end - 1], m[3] || m[1]) }, | |
| 286 | 286 | range: d.range, | |
| 287 | 287 | }; | |
| 288 | 288 | paint(fig, span, { reveal: window.__cwRestored === undefined, flash }); | |
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | 291 | window.addEventListener('hashchange', () => applyHash()); | |
| 292 | 292 | // Wait for diagrams to take their final size so scroll positions are stable. | |
| 293 | 293 | layoutReady.then(() => { | |
| 294 | 294 | if (window.__cwRestored !== undefined) window.scrollTo(0, window.__cwRestored); | |
| 295 | 295 | applyHash({ flash: window.__cwRestored === undefined }); | |
| 296 | 296 | }); | |
| 297 | 297 | ||
| 298 | 298 | // ---- copying ---------------------------------------------------------------------- | |
| 299 | 299 | function sectionTitle(fig) { | |
| 300 | 300 | let title = null; | |
| 301 | 301 | for (const h of $$('.cw-article h1, .cw-article h2, .cw-article h3')) { | |
| 302 | 302 | if (h.compareDocumentPosition(fig) & Node.DOCUMENT_POSITION_FOLLOWING) title = h.textContent.trim(); | |
| 303 | 303 | else break; | |
| 304 | 304 | } | |
| 305 | 305 | return title; | |
| 306 | 306 | } | |
| 307 | 307 | ||
| 308 | 308 | function promptText(fig, range) { | |
| 309 | 309 | const tokens = fig.dataset.link.replace(/^-C \S+ /, ''); | |
| 310 | 310 | const repo = fig.dataset.repo; | |
| 311 | 311 | const lines = range ? ` lines ${range}` : ''; | |
| 312 | 312 | const where = [ | |
| 313 | 313 | CFG.walkPath ? `walk: ${CFG.walkPath}` : null, | |
| 314 | 314 | sectionTitle(fig) ? `section "${sectionTitle(fig)}"` : null, | |
| 315 | 315 | ].filter(Boolean).join(', '); | |
| 316 | 316 | let ref; | |
| 317 | 317 | if (/^show [^:\s]+$/.test(tokens)) ref = `${fig.dataset.path}${lines} (working tree of ${repo})`; | |
| 318 | 318 | else ref = `\`git -C ${repo} ${tokens}\`${lines}${range && fig.dataset.mode === 'diff' ? ' (L = old side, R = new side)' : ''}`; | |
| 319 | 319 | return `${ref}${where ? `\n${where}` : ''}\n${snippetUrl(fig, range)}`; | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | 322 | function copyLink(fig, forPrompt) { | |
| 323 | 323 | const range = current && current.fig === fig ? current.range : null; | |
| 324 | 324 | if (forPrompt) copy(promptText(fig, range), 'Copied reference for a prompt'); | |
| 325 | 325 | else copy(snippetUrl(fig, range), range ? `Copied link to ${range}` : 'Copied link to snippet'); | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | 328 | document.addEventListener('click', (e) => { | |
| 329 | 329 | const linkBtn = e.target.closest('.cw-link-btn'); | |
| 330 | 330 | if (linkBtn) return copyLink(linkBtn.closest('.cw-snippet'), e.shiftKey); | |
| 331 | 331 | const cmdBtn = e.target.closest('.cw-copy-cmd'); | |
| 332 | 332 | if (cmdBtn) { | |
| 333 | 333 | const code = cmdBtn.parentElement.querySelector('code'); | |
| 334 | 334 | copy(code.textContent, 'Copied command'); | |
| 335 | 335 | } | |
| 336 | 336 | }); | |
| 337 | 337 | ||
| 338 | 338 | // ---- keyboard --------------------------------------------------------------------- | |
| 339 | 339 | const headings = () => $$('.cw-article h2, .cw-article h3'); | |
| 340 | 340 | ||
| 341 | 341 | document.addEventListener('keydown', (e) => { | |
| 342 | 342 | if (e.metaKey || e.ctrlKey || e.altKey) return; | |
| 343 | 343 | if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable]')) return; | |
| 344 | 344 | if (e.key === 'c') { | |
| 345 | 345 | e.preventDefault(); | |
| 346 | 346 | openComposer(); | |
| 347 | 347 | } else if (e.key === 'n' || e.key === 'p') { | |
| 348 | 348 | jumpToThread(e.key === 'n' ? 1 : -1); | |
| 349 | 349 | } else if (e.key === 'y' || e.key === 'Y') { | |
| 350 | 350 | if (!current) return toast('Click a line number to select lines first'); | |
| 351 | 351 | copyLink(current.fig, e.key === 'Y'); | |
| 352 | 352 | } else if (e.key === 'j' || e.key === 'k') { | |
| 353 | 353 | const hs = headings(); | |
| 354 | 354 | if (!hs.length) return; | |
| 355 | 355 | const y = 30; | |
| 356 | 356 | let target; | |
| 357 | 357 | if (e.key === 'j') target = hs.find((h) => h.getBoundingClientRect().top > y + 1); | |
| 358 | 358 | else target = [...hs].reverse().find((h) => h.getBoundingClientRect().top < y - 1); | |
| 359 | 359 | if (target) target.scrollIntoView({ block: 'start' }); | |
| 360 | 360 | else if (e.key === 'k') window.scrollTo({ top: 0 }); | |
| 361 | 361 | } else if (e.key === 'Escape' && current) { | |
| 362 | 362 | clearSelection(); | |
| 363 | 363 | history.replaceState(null, '', location.pathname); | |
| 364 | 364 | } | |
| 365 | 365 | }); | |
| 366 | 366 | ||
| 367 | 367 | // ---- --stat summaries: files shown elsewhere in the walk link to their snippet -------- | |
| 368 | 368 | for (const row of $$('.cw-sum-file')) { | |
| 369 | 369 | const repo = row.closest('.cw-summary').dataset.repo; | |
| 370 | 370 | const target = $$('.cw-snippet').find((f) => f.dataset.repo === repo && f.dataset.path === row.dataset.path); | |
| 371 | 371 | if (!target) continue; | |
| 372 | 372 | const label = row.querySelector('.cw-sum-path'); | |
| 373 | 373 | const btn = document.createElement('button'); | |
| 374 | 374 | btn.type = 'button'; | |
| 375 | 375 | btn.className = 'cw-sum-path'; | |
| 376 | 376 | btn.title = 'Jump to this file in the walk'; | |
| 377 | 377 | btn.textContent = label.textContent; | |
| 378 | 378 | btn.addEventListener('click', () => { | |
| 379 | 379 | target.scrollIntoView({ block: 'start' }); | |
| 380 | 380 | window.scrollBy(0, -24); | |
| 381 | 381 | target.classList.remove('flash'); | |
| 382 | 382 | void target.offsetWidth; | |
| 383 | 383 | target.classList.add('flash'); | |
| 384 | 384 | }); | |
| 385 | 385 | label.replaceWith(btn); | |
| 386 | 386 | } | |
| 387 | 387 | ||
| 388 | 388 | // ---- review comments ----------------------------------------------------------------- | |
| 389 | 389 | // Threads are stored by the server in <walk>.comments.json and anchored to a snippet's | |
| 390 | 390 | // link + line range, with a copy of the lines, so Claude can act on them via the CLI. | |
| 391 | 391 | // editBase: the text each open editor started from. Saves send it so the server can refuse to | |
| 392 | 392 | // overwrite a message that changed meanwhile (e.g. Claude edited it from the CLI). | |
| 393 | 393 | const review = { me: 'You', comments: [], drafts: new Map(), replying: new Set(), editing: new Set(), editBase: new Map(), loaded: false }; | |
| 394 | 394 | let resolveCommentsReady; | |
| 395 | 395 | const commentsReady = new Promise((r) => { resolveCommentsReady = r; }); | |
| 396 | 396 | ||
| 397 | 397 | const el = (tag, cls, html) => { | |
| 398 | 398 | const n = document.createElement(tag); | |
| 399 | 399 | if (cls) n.className = cls; | |
| 400 | 400 | if (html !== undefined) n.innerHTML = html; | |
| 401 | 401 | return n; | |
| 402 | 402 | }; | |
| 403 | 403 | const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); | |
| 404 | 404 | ||
| 405 | 405 | function ago(iso) { | |
| 406 | 406 | const s = (Date.now() - new Date(iso).getTime()) / 1000; | |
| 407 | 407 | if (s < 45) return 'just now'; | |
| 408 | 408 | if (s < 3600) return `${Math.round(s / 60)}m ago`; | |
| 409 | 409 | if (s < 86400) return `${Math.round(s / 3600)}h ago`; | |
| 410 | 410 | if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`; | |
| 411 | 411 | return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); | |
| 412 | 412 | } | |
| 413 | 413 | ||
| 414 | 414 | async function api(action, { id, body } = {}) { | |
| 415 | 415 | const q = new URLSearchParams({ walk: CFG.walk }); | |
| 416 | 416 | if (id) q.set('id', id); | |
| 417 | 417 | const res = await fetch(`/api/comments${action}?${q}`, { | |
| 418 | 418 | method: 'POST', | |
| 419 | 419 | headers: { 'content-type': 'application/json' }, | |
| 420 | 420 | body: JSON.stringify(body || {}), | |
| 421 | 421 | }); | |
| 422 | 422 | const data = await res.json().catch(() => ({})); | |
| 423 | 423 | if (!res.ok) throw Object.assign(new Error(data.error || `request failed (${res.status})`), { status: res.status, data }); | |
| 424 | 424 | return data; | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | 427 | async function loadComments() { | |
| 428 | 428 | if (!CFG.walk) return; | |
| 429 | 429 | try { | |
| 430 | 430 | const res = await fetch(`/api/comments?walk=${encodeURIComponent(CFG.walk)}`); | |
| 431 | 431 | if (!res.ok) return; | |
| 432 | 432 | const data = await res.json(); | |
| 433 | 433 | review.me = data.me || 'You'; | |
| 434 | 434 | review.comments = data.comments || []; | |
| 435 | 435 | review.loaded = true; | |
| 436 | 436 | renderThreads(); | |
| 437 | 437 | } catch (e) { | |
| 438 | 438 | console.error(e); | |
| 439 | 439 | } finally { | |
| 440 | 440 | resolveCommentsReady(); | |
| 441 | 441 | } | |
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | 444 | function anchorFor(fig, range) { | |
| 445 | 445 | const span = spanFor(fig, range); | |
| 446 | 446 | const rows = rowsOf(fig).slice(span.start, span.end); | |
| 447 | 447 | const file = fig.dataset.mode === 'file'; | |
| 448 | 448 | return { | |
| 449 | 449 | link: fig.dataset.link, | |
| 450 | 450 | range, | |
| 451 | 451 | path: fig.dataset.path, | |
| 452 | 452 | mode: fig.dataset.mode, | |
| 453 | 453 | repo: fig.dataset.repo, | |
| 454 | 454 | repoName: fig.dataset.repoName, | |
| 455 | 455 | command: fig.dataset.link, | |
| 456 | 456 | section: sectionTitle(fig), | |
| 457 | 457 | lines: rows.map((r) => ({ | |
| 458 | 458 | o: !file && r.dataset.l ? Number(r.dataset.l) : null, | |
| 459 | 459 | n: file ? Number(r.dataset.l) : r.dataset.r ? Number(r.dataset.r) : null, | |
| 460 | 460 | t: r.classList.contains('add') ? '+' : r.classList.contains('del') ? '-' : ' ', | |
| 461 | 461 | text: r.querySelector('.code').textContent, | |
| 462 | 462 | })), | |
| 463 | 463 | }; | |
| 464 | 464 | } | |
| 465 | 465 | ||
| 466 | 466 | // -- selection toolbar -- | |
| 467 | 467 | const selbar = el('div', 'cw-selbar'); | |
| 468 | 468 | selbar.setAttribute('role', 'toolbar'); | |
| 469 | 469 | document.body.appendChild(selbar); | |
| 470 | 470 | ||
| 471 | 471 | function onSelectionChange() { | |
| 472 | 472 | if (!current || !current.range || !CFG.walk) { | |
| 473 | 473 | selbar.classList.remove('show'); | |
| 474 | 474 | document.body.classList.remove('has-selbar'); | |
| 475 | 475 | return; | |
| 476 | 476 | } | |
| 477 | 477 | selbar.innerHTML = `<span class="cw-selbar-range"><code>${escHtml(current.fig.dataset.path.split('/').pop())}</code> ${escHtml(current.range)}</span>` | |
| 478 | 478 | + '<button type="button" data-act="comment">Comment <kbd>c</kbd></button>' | |
| 479 | 479 | + '<button type="button" data-act="link">Copy link <kbd>y</kbd></button>' | |
| 480 | 480 | + '<button type="button" data-act="clear" aria-label="Clear selection" title="Clear selection (Esc)">â</button>'; | |
| 481 | 481 | selbar.classList.add('show'); | |
| 482 | 482 | document.body.classList.add('has-selbar'); | |
| 483 | 483 | } | |
| 484 | 484 | ||
| 485 | 485 | selbar.addEventListener('click', (e) => { | |
| 486 | 486 | const act = e.target.closest('button')?.dataset.act; | |
| 487 | 487 | if (act === 'comment') openComposer(); | |
| 488 | 488 | else if (act === 'link' && current) copyLink(current.fig, e.shiftKey); | |
| 489 | 489 | else if (act === 'clear') { | |
| 490 | 490 | clearSelection(); | |
| 491 | 491 | history.replaceState(null, '', location.pathname); | |
| 492 | 492 | } | |
| 493 | 493 | }); | |
| 494 | 494 | ||
| 495 | 495 | // -- composer for a new comment -- | |
| 496 | 496 | let composer = null; // { fig, range, row } | |
| 497 | 497 | ||
| 498 | 498 | function closeComposer() { | |
| 499 | 499 | composer?.row.remove(); | |
| 500 | 500 | composer = null; | |
| 501 | 501 | } | |
| 502 | 502 | ||
| 503 | 503 | function openComposer() { | |
| 504 | 504 | if (!current || !current.range) return toast('Select lines first: click a line number (shift-click for a range)'); | |
| 505 | 505 | const { fig, range } = current; | |
| 506 | 506 | const span = spanFor(fig, range); | |
| 507 | 507 | if (!span) return; | |
| 508 | 508 | if (composer && composer.fig === fig && composer.range === range) { | |
| 509 | 509 | composer.row.querySelector('textarea').focus(); | |
| 510 | 510 | return; | |
| 511 | 511 | } | |
| 512 | 512 | closeComposer(); | |
| 513 | 513 | const rows = rowsOf(fig); | |
| 514 | 514 | const cols = rows[0].children.length; | |
| 515 | 515 | const row = el('tr', 'cw-comment-row cw-composer-row'); | |
| 516 | 516 | const td = el('td'); | |
| 517 | 517 | td.colSpan = cols; | |
| 518 | 518 | td.innerHTML = `<div class="cw-composer"> | |
| 519 | 519 | <div class="cw-composer-head">Comment on <b>${escHtml(range)}</b></div> | |
| 520 | 520 | <textarea rows="3" placeholder="Leave a review comment⊠(Markdown supported)"></textarea> | |
| 521 | 521 | <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> | |
| 522 | 522 | </div>`; | |
| 523 | 523 | row.appendChild(td); | |
| 524 | 524 | rows[span.end - 1].after(row); | |
| 525 | 525 | composer = { fig, range, row }; | |
| 526 | 526 | const ta = td.querySelector('textarea'); | |
| 527 | 527 | ta.value = review.drafts.get('new') || ''; | |
| 528 | 528 | ta.focus(); | |
| 529 | 529 | } | |
| 530 | 530 | ||
| 531 | 531 | async function saveComposer() { | |
| 532 | 532 | if (!composer) return; | |
| 533 | 533 | const ta = composer.row.querySelector('textarea'); | |
| 534 | 534 | const body = ta.value.trim(); | |
| 535 | 535 | if (!body) return ta.focus(); | |
| 536 | 536 | const btn = composer.row.querySelector('[data-act="save"]'); | |
| 537 | 537 | btn.disabled = true; | |
| 538 | 538 | try { | |
| 539 | 539 | const { comment } = await api('', { body: { anchor: anchorFor(composer.fig, composer.range), body } }); | |
| 540 | 540 | review.drafts.delete('new'); | |
| 541 | 541 | closeComposer(); | |
| 542 | 542 | const first = !review.comments.length; | |
| 543 | 543 | review.comments.push(comment); | |
| 544 | 544 | renderThreads(); | |
| 545 | 545 | toast(first ? 'Comment saved. When youâre done, ask Claude to âaddress my code walk commentsâ' : 'Comment saved'); | |
| 546 | 546 | } catch (e) { | |
| 547 | 547 | btn.disabled = false; | |
| 548 | 548 | toast(e.message); | |
| 549 | 549 | } | |
| 550 | 550 | } | |
| 551 | 551 | ||
| 552 | 552 | // -- thread rendering -- | |
| 553 | 553 | function avatar(name) { | |
| 554 | 554 | const claude = /^claude$/i.test(name); | |
| 555 | 555 | return `<span class="cw-avatar${claude ? ' claude' : ''}" aria-hidden="true">${claude ? 'âł' : escHtml((name || '?').trim().charAt(0).toUpperCase())}</span>`; | |
| 556 | 556 | } | |
| 557 | 557 | ||
| 558 | 558 | // A message key is the thread id, or "<thread id>/<reply id>" for a reply. | |
| 559 | 559 | function messageHtml(m, { id, isRoot }) { | |
| 560 | 560 | const key = isRoot ? id : `${id}/${m.id}`; | |
| 561 | 561 | const k = escHtml(key); | |
| 562 | 562 | const editing = review.editing.has(key); | |
| 563 | 563 | const what = !isRoot ? 'reply' : m.replies?.length ? 'thread' : 'comment'; | |
| 564 | 564 | const menu = review.confirmingDelete === key | |
| 565 | 565 | ? `<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>` | |
| 566 | 566 | : `<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>`; | |
| 567 | 567 | return `<div class="cw-msg${isRoot ? '' : ' reply'}" data-key="${k}"> | |
| 568 | 568 | ${avatar(m.author)} | |
| 569 | 569 | <div class="cw-msg-main"> | |
| 570 | 570 | <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> | |
| 571 | 571 | ${editing | |
| 572 | 572 | ? `<div class="cw-composer inline edit">${conflictHtml(key, m)}<textarea rows="1" data-draft="edit:${k}"></textarea></div>` | |
| 573 | 573 | : `<div class="cw-msg-body">${m.bodyHtml}</div>`} | |
| 574 | 574 | </div> | |
| 575 | 575 | </div>`; | |
| 576 | 576 | } | |
| 577 | 577 | ||
| 578 | 578 | /** Shown in an open editor when the message changed after editing started. */ | |
| 579 | 579 | function conflictHtml(key, m) { | |
| 580 | 580 | const base = review.editBase.get(key); | |
| 581 | 581 | if (base === undefined || base === m.body) return ''; | |
| 582 | 582 | const k = escHtml(key); | |
| 583 | 583 | return `<div class="cw-conflict" role="alert"> | |
| 584 | 584 | <div class="cw-conflict-head">This message changed while you were editing. Your text below hasnât been saved.</div> | |
| 585 | 585 | <div class="cw-conflict-label">Latest version</div> | |
| 586 | 586 | <div class="cw-conflict-current">${escHtml(m.body)}</div> | |
| 587 | 587 | <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> | |
| 588 | 588 | </div>`; | |
| 589 | 589 | } | |
| 590 | 590 | ||
| 591 | 591 | function threadEl(c, { quote = false } = {}) { | |
| 592 | 592 | const resolved = c.status === 'resolved'; | |
| 593 | 593 | const expanded = !resolved || review.expanded?.has(c.id); | |
| 594 | 594 | const node = el('div', `cw-thread${resolved ? ' resolved' : ''}${expanded ? '' : ' collapsed'}`); | |
| 595 | 595 | node.id = `comment-${c.id}`; | |
| 596 | 596 | node.dataset.id = c.id; | |
| 597 | 597 | const a = c.anchor || {}; | |
| 598 | 598 | const summary = (c.body || '').split('\n')[0].slice(0, 120); | |
| 599 | 599 | let html = ''; | |
| 600 | 600 | if (resolved) { | |
| 601 | 601 | 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>`; | |
| 602 | 602 | } | |
| 603 | 603 | if (expanded) { | |
| 604 | 604 | if (quote) { | |
| 605 | 605 | 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>`; | |
| 606 | 606 | } | |
| 607 | 607 | html += messageHtml(c, { id: c.id, isRoot: true }); | |
| 608 | 608 | for (const r of c.replies) html += messageHtml(r, { id: c.id, isRoot: false }); | |
| 609 | 609 | // While a message is being edited, its Cancel / Save take the place of Reply / Resolve. | |
| 610 | 610 | const editKey = [...review.editing].find((key) => key === c.id || key.startsWith(`${c.id}/`)); | |
| 611 | 611 | if (editKey) { | |
| 612 | 612 | node.classList.add('editing'); | |
| 613 | 613 | const k = escHtml(editKey); | |
| 614 | 614 | 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>`; | |
| 615 | 615 | } else if (review.replying.has(c.id)) { | |
| 616 | 616 | 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>`; | |
| 617 | 617 | } else { | |
| 618 | 618 | 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>`; | |
| 619 | 619 | } | |
| 620 | 620 | } | |
| 621 | 621 | node.innerHTML = html; | |
| 622 | 622 | return node; | |
| 623 | 623 | } | |
| 624 | 624 | ||
| 625 | 625 | function renderThreads() { | |
| 626 | 626 | // Keep drafts and focus across re-renders (e.g. when Claude replies from the CLI). | |
| 627 | 627 | const active = document.activeElement; | |
| 628 | 628 | const activeKey = active?.dataset?.draft; | |
| 629 | 629 | const caret = activeKey ? [active.selectionStart, active.selectionEnd] : null; | |
| 630 | 630 | $$('textarea[data-draft]').forEach((t) => review.drafts.set(t.dataset.draft, t.value)); | |
| 631 | 631 | if (composer) review.drafts.set('new', composer.row.querySelector('textarea').value); | |
| 632 | 632 | ||
| 633 | 633 | $$('.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')); | |
| 635 | 635 | ||
| 636 | 636 | const groups = new Map(); | |
| 637 | 637 | const unplaced = []; | |
| 638 | 638 | for (const c of review.comments) { | |
| 639 | 639 | const a = c.anchor; | |
| 640 | 640 | const fig = a && a.link && a.range ? findSnippet(a.link, a.range) : null; | |
| 641 | 641 | const span = fig && spanFor(fig, a.range); | |
| 642 | 642 | if (!span) { unplaced.push(c); continue; } | |
| 643 | 643 | const rows = rowsOf(fig); | |
| 644 | 644 | const endRow = rows[span.end - 1]; | |
| 645 | 645 | if (!groups.has(endRow)) groups.set(endRow, []); | |
| 646 | 646 | groups.get(endRow).push(c); | |
| 647 | 647 | for (let i = span.start; i < span.end; i++) { | |
| 648 | 648 | const r = rows[i]; | |
| 649 | 649 | if (c.status !== 'resolved') { | |
| 650 | 650 | r.classList.add('commented'); | |
| 651 | 651 | r.classList.remove('resolved-only'); | |
| 652 | 652 | openFold(r); | |
| 653 | 653 | } else if (!r.classList.contains('commented')) { | |
| 654 | 654 | r.classList.add('commented', 'resolved-only'); | |
| 655 | 655 | } | |
| 656 | 656 | } | |
| 657 | + | rows[span.start].classList.add('cm-start'); | |
| 658 | + | rows[span.end - 1].classList.add('cm-end'); | |
| 657 | 659 | } | |
| 658 | 660 | for (const [endRow, list] of groups) { | |
| 659 | 661 | const tr = el('tr', 'cw-comment-row cw-thread-row'); | |
| 660 | 662 | const td = el('td'); | |
| 661 | 663 | td.colSpan = endRow.children.length; | |
| 662 | 664 | const wrap = el('div', 'cw-threads'); | |
| 663 | 665 | list.forEach((c) => wrap.appendChild(threadEl(c))); | |
| 664 | 666 | td.appendChild(wrap); | |
| 665 | 667 | tr.appendChild(td); | |
| 666 | 668 | endRow.after(tr); | |
| 667 | 669 | } | |
| 668 | 670 | if (unplaced.length) { | |
| 669 | 671 | const sec = el('section', 'cw-unplaced'); | |
| 670 | 672 | sec.innerHTML = '<h2>Comments on code no longer in this walk</h2>'; | |
| 671 | 673 | unplaced.forEach((c) => sec.appendChild(threadEl(c, { quote: true }))); | |
| 672 | 674 | document.querySelector('.cw-article')?.appendChild(sec); | |
| 673 | 675 | } | |
| 674 | 676 | ||
| 675 | 677 | for (const [key, value] of review.drafts) { | |
| 676 | 678 | const t = document.querySelector(`textarea[data-draft="${CSS.escape(key)}"]`); | |
| 677 | 679 | if (t) t.value = value; | |
| 678 | 680 | } | |
| 679 | 681 | for (const key of review.editing) { | |
| 680 | 682 | const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 681 | 683 | const m = messageFor(key); | |
| 682 | 684 | if (t && !review.drafts.has(`edit:${key}`) && m) t.value = m.body; | |
| 683 | 685 | } | |
| 684 | 686 | $$('.cw-composer textarea').forEach(autosize); | |
| 685 | 687 | if (activeKey) { | |
| 686 | 688 | const t = document.querySelector(`textarea[data-draft="${CSS.escape(activeKey)}"]`); | |
| 687 | 689 | if (t) { t.focus({ preventScroll: true }); if (caret) t.setSelectionRange(caret[0], caret[1]); } | |
| 688 | 690 | } | |
| 689 | 691 | } | |
| 690 | 692 | ||
| 691 | 693 | /** Grow a textarea to fit its content so the whole comment is visible while editing. */ | |
| 692 | 694 | function autosize(t) { | |
| 693 | 695 | t.style.height = 'auto'; | |
| 694 | 696 | t.style.height = `${t.scrollHeight + (t.offsetHeight - t.clientHeight)}px`; | |
| 695 | 697 | } | |
| 696 | 698 | ||
| 697 | 699 | function messageFor(key) { | |
| 698 | 700 | const [id, replyId] = key.split('/'); | |
| 699 | 701 | const c = review.comments.find((x) => x.id === id); | |
| 700 | 702 | return c && (replyId ? c.replies.find((r) => r.id === replyId) : c); | |
| 701 | 703 | } | |
| 702 | 704 | ||
| 703 | 705 | // -- thread actions -- | |
| 704 | 706 | document.addEventListener('click', async (e) => { | |
| 705 | 707 | const btn = e.target.closest('.cw-thread button, .cw-composer-row button'); | |
| 706 | 708 | if (!btn) return; | |
| 707 | 709 | const act = btn.dataset.act; | |
| 708 | 710 | if (act === 'cancel') { review.drafts.delete('new'); return closeComposer(); } | |
| 709 | 711 | if (act === 'save') return saveComposer(); | |
| 710 | 712 | const thread = btn.closest('.cw-thread'); | |
| 711 | 713 | if (!thread) return; | |
| 712 | 714 | const id = thread.dataset.id; | |
| 713 | 715 | const c = review.comments.find((x) => x.id === id); | |
| 714 | 716 | const replace = (updated) => { | |
| 715 | 717 | const i = review.comments.findIndex((x) => x.id === id); | |
| 716 | 718 | if (i !== -1) review.comments[i] = updated; | |
| 717 | 719 | }; | |
| 718 | 720 | try { | |
| 719 | 721 | if (act === 'toggle') { | |
| 720 | 722 | review.expanded ??= new Set(); | |
| 721 | 723 | if (review.expanded.has(id)) review.expanded.delete(id); else review.expanded.add(id); | |
| 722 | 724 | renderThreads(); | |
| 723 | 725 | } else if (act === 'reply') { | |
| 724 | 726 | review.replying.add(id); | |
| 725 | 727 | renderThreads(); | |
| 726 | 728 | document.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`)?.focus(); | |
| 727 | 729 | } else if (act === 'cancel-reply') { | |
| 728 | 730 | review.replying.delete(id); | |
| 729 | 731 | review.drafts.delete(`reply:${id}`); | |
| 730 | 732 | renderThreads(); | |
| 731 | 733 | } else if (act === 'send-reply') { | |
| 732 | 734 | const t = thread.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`); | |
| 733 | 735 | if (!t.value.trim()) return t.focus(); | |
| 734 | 736 | btn.disabled = true; | |
| 735 | 737 | const { comment } = await api('/reply', { id, body: { body: t.value } }); | |
| 736 | 738 | review.replying.delete(id); | |
| 737 | 739 | review.drafts.delete(`reply:${id}`); | |
| 738 | 740 | replace(comment); | |
| 739 | 741 | renderThreads(); | |
| 740 | 742 | } else if (act === 'resolve' || act === 'reopen') { | |
| 741 | 743 | const { comment } = await api('/status', { id, body: { status: act === 'resolve' ? 'resolved' : 'open' } }); | |
| 742 | 744 | replace(comment); | |
| 743 | 745 | review.expanded?.delete(id); | |
| 744 | 746 | renderThreads(); | |
| 745 | 747 | toast(act === 'resolve' ? 'Resolved' : 'Reopened'); | |
| 746 | 748 | } else if (act === 'edit') { | |
| 747 | 749 | const key = btn.dataset.key; | |
| 748 | 750 | // One message per thread at a time, since the thread's action row holds its Save / Cancel. | |
| 749 | 751 | for (const other of [...review.editing]) if (other.split('/')[0] === id) review.editing.delete(other); | |
| 750 | 752 | review.replying.delete(id); | |
| 751 | 753 | review.editing.add(key); | |
| 752 | 754 | review.editBase.set(key, messageFor(key)?.body ?? ''); | |
| 753 | 755 | renderThreads(); | |
| 754 | 756 | const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 755 | 757 | // Keep the page still: the editor takes the text's place and shows all of it. | |
| 756 | 758 | if (t) { t.focus({ preventScroll: true }); t.setSelectionRange(t.value.length, t.value.length); } | |
| 757 | 759 | } else if (act === 'cancel-edit') { | |
| 758 | 760 | const key = btn.dataset.key; | |
| 759 | 761 | review.editing.delete(key); | |
| 760 | 762 | review.editBase.delete(key); | |
| 761 | 763 | review.drafts.delete(`edit:${key}`); | |
| 762 | 764 | renderThreads(); | |
| 763 | 765 | } else if (act === 'keep-mine') { | |
| 764 | 766 | // The user has now seen the latest text, so it becomes the base for their save. | |
| 765 | 767 | const key = btn.dataset.key; | |
| 766 | 768 | review.editBase.set(key, messageFor(key)?.body ?? ''); | |
| 767 | 769 | thread.querySelector(`[data-act="save-edit"][data-key="${CSS.escape(key)}"]`)?.click(); | |
| 768 | 770 | } else if (act === 'take-latest') { | |
| 769 | 771 | const key = btn.dataset.key; | |
| 770 | 772 | const latest = messageFor(key)?.body ?? ''; | |
| 771 | 773 | review.editBase.set(key, latest); | |
| 772 | 774 | review.drafts.set(`edit:${key}`, latest); | |
| 773 | 775 | thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`).value = latest; | |
| 774 | 776 | renderThreads(); | |
| 775 | 777 | } else if (act === 'save-edit') { | |
| 776 | 778 | const key = btn.dataset.key; | |
| 777 | 779 | const t = thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 778 | 780 | if (!t.value.trim()) return t.focus(); | |
| 779 | 781 | btn.disabled = true; | |
| 780 | 782 | try { | |
| 781 | 783 | const { comment } = await api('/edit', { id, body: { body: t.value, reply: key.split('/')[1] || null, base: review.editBase.get(key) } }); | |
| 782 | 784 | review.editing.delete(key); | |
| 783 | 785 | review.editBase.delete(key); | |
| 784 | 786 | review.drafts.delete(`edit:${key}`); | |
| 785 | 787 | replace(comment); | |
| 786 | 788 | } catch (err) { | |
| 787 | 789 | if (err.status !== 409) throw err; | |
| 788 | 790 | // Someone else changed it: keep the draft, show their latest text, let the user choose. | |
| 789 | 791 | const m = messageFor(key); | |
| 790 | 792 | if (m) m.body = err.data.current; | |
| 791 | 793 | toast('Not saved: this message changed while you were editing'); | |
| 792 | 794 | } | |
| 793 | 795 | renderThreads(); | |
| 794 | 796 | } else if (act === 'delete') { | |
| 795 | 797 | // Inline confirmation: native confirm() is blocked in some embedded browsers. | |
| 796 | 798 | review.confirmingDelete = btn.dataset.key; | |
| 797 | 799 | renderThreads(); | |
| 798 | 800 | document.querySelector(`.cw-msg[data-key="${CSS.escape(btn.dataset.key)}"] [data-act="confirm-delete"]`)?.focus(); | |
| 799 | 801 | } else if (act === 'cancel-delete') { | |
| 800 | 802 | review.confirmingDelete = null; | |
| 801 | 803 | renderThreads(); | |
| 802 | 804 | } else if (act === 'confirm-delete') { | |
| 803 | 805 | const replyId = btn.dataset.key.split('/')[1] || null; | |
| 804 | 806 | btn.disabled = true; | |
| 805 | 807 | const { comment } = await api('/delete', { id, body: { reply: replyId } }); | |
| 806 | 808 | review.confirmingDelete = null; | |
| 807 | 809 | if (replyId) replace(comment); | |
| 808 | 810 | else review.comments = review.comments.filter((x) => x.id !== id); | |
| 809 | 811 | renderThreads(); | |
| 810 | 812 | toast(replyId ? 'Reply deleted' : c && c.replies.length ? 'Thread deleted' : 'Comment deleted'); | |
| 811 | 813 | } else if (act === 'copy-link') { | |
| 812 | 814 | copy(`${location.origin}${location.pathname}#comment-${id}`, 'Copied link to comment'); | |
| 813 | 815 | } | |
| 814 | 816 | } catch (err) { | |
| 815 | 817 | btn.disabled = false; | |
| 816 | 818 | toast(err.message); | |
| 817 | 819 | } | |
| 818 | 820 | }); | |
| 819 | 821 | ||
| 820 | 822 | document.addEventListener('keydown', (e) => { | |
| 821 | 823 | const t = e.target; | |
| 822 | 824 | if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return; | |
| 823 | 825 | // Edit boxes keep their buttons in the thread's action row; other composers inline. | |
| 824 | 826 | const scope = t.closest('.cw-composer.edit') ? t.closest('.cw-thread') : t.closest('.cw-composer'); | |
| 825 | 827 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { | |
| 826 | 828 | e.preventDefault(); | |
| 827 | 829 | scope.querySelector('[data-act="save-edit"], .cw-btn.primary')?.click(); | |
| 828 | 830 | } else if (e.key === 'Escape') { | |
| 829 | 831 | e.preventDefault(); | |
| 830 | 832 | scope.querySelector('[data-act="cancel-edit"], .cw-btn:not(.primary)')?.click(); | |
| 831 | 833 | } | |
| 832 | 834 | }); | |
| 833 | 835 | ||
| 834 | 836 | document.addEventListener('input', (e) => { | |
| 835 | 837 | const t = e.target; | |
| 836 | 838 | if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return; | |
| 837 | 839 | autosize(t); | |
| 838 | 840 | if (t.closest('.cw-composer-row')) review.drafts.set('new', t.value); | |
| 839 | 841 | }); | |
| 840 | 842 | ||
| 841 | 843 | // -- navigation between open threads (n / p) -- | |
| 842 | 844 | function openThreads() { | |
| 843 | 845 | return $$('.cw-thread').filter((t) => !t.classList.contains('resolved')); | |
| 844 | 846 | } | |
| 845 | 847 | ||
| 846 | 848 | let threadCursor = -1; | |
| 847 | 849 | function jumpToThread(dir) { | |
| 848 | 850 | const threads = openThreads(); | |
| 849 | 851 | if (!threads.length) return toast(review.comments.length ? 'No open comments' : 'No comments yet â select lines and press c'); | |
| 850 | 852 | const y = window.innerHeight * 0.3; | |
| 851 | 853 | if (dir > 0) threadCursor = threads.findIndex((t) => t.getBoundingClientRect().top > y + 4); | |
| 852 | 854 | else threadCursor = threads.map((t) => t.getBoundingClientRect().top < y - 4).lastIndexOf(true); | |
| 853 | 855 | if (threadCursor === -1) threadCursor = dir > 0 ? 0 : threads.length - 1; | |
| 854 | 856 | focusThread(threads[threadCursor].dataset.id); | |
| 855 | 857 | } | |
| 856 | 858 | ||
| 857 | 859 | function focusThread(id) { | |
| 858 | 860 | let node = document.getElementById(`comment-${id}`); | |
| 859 | 861 | if (!node) { | |
| 860 | 862 | const hit = review.comments.find((c) => c.id.startsWith(id)); | |
| 861 | 863 | node = hit && document.getElementById(`comment-${hit.id}`); | |
| 862 | 864 | } | |
| 863 | 865 | if (!node) return toast('That comment isnât in this walk'); | |
| 864 | 866 | openFold(node); | |
| 865 | 867 | const top = window.scrollY + node.getBoundingClientRect().top - window.innerHeight * 0.3; | |
| 866 | 868 | window.scrollTo({ top: Math.max(0, top) }); | |
| 867 | 869 | node.classList.remove('flash'); | |
| 868 | 870 | void node.offsetWidth; | |
| 869 | 871 | node.classList.add('flash'); | |
| 870 | 872 | } | |
| 871 | 873 | ||
| 872 | 874 | loadComments(); | |
| 873 | 875 | ||
| 874 | 876 | // ---- TOC active section ------------------------------------------------------------- | |
| 875 | 877 | const tocLinks = $$('.cw-toc a'); | |
| 876 | 878 | if (tocLinks.length) { | |
| 877 | 879 | const byId = new Map(tocLinks.map((a) => [a.dataset.id, a])); | |
| 878 | 880 | const hs = $$('.cw-article h2, .cw-article h3').filter((h) => byId.has(h.id)); | |
| 879 | 881 | const update = () => { | |
| 880 | 882 | // Above the first heading, the title link at the top of the sidebar is the active one. | |
| 881 | 883 | let active = null; | |
| 882 | 884 | for (const h of hs) if (h.getBoundingClientRect().top < window.innerHeight * 0.25) active = h; | |
| 883 | 885 | const id = active ? active.id : ''; | |
| 884 | 886 | tocLinks.forEach((a) => a.classList.toggle('active', a.dataset.id === id)); | |
| 885 | 887 | }; | |
| 886 | 888 | tocLinks[0].addEventListener('click', (e) => { | |
| 887 | 889 | e.preventDefault(); | |
| 888 | 890 | window.scrollTo({ top: 0 }); | |
| 889 | 891 | history.replaceState(null, '', location.pathname); | |
| 890 | 892 | }); | |
| 891 | 893 | window.addEventListener('scroll', update, { passive: true }); | |
| 892 | 894 | update(); | |
| 893 | 895 | } | |
| 894 | 896 | })(); | |
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:
| 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 | ||
Lines and links
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:
| 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 | |
| 4 | export class RefError extends Error {} |
| 5 | |
| 6 | export const SELECTION_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/; |
| 7 | |
| 8 | export 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 | |
| 20 | export 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 | |
| 40 | export 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 | */ |
| 55 | export 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 | |
| 123 | export 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 | |
| 135 | function quoteToken(t) { |
| 136 | return /[\s"']/.test(t) ? `"${t.replace(/["\\]/g, '\\$&')}"` : t; |
| 137 | } |
| 138 | |
| 139 | // ---- URL fragments -------------------------------------------------------- |
| 140 | |
| 141 | const FRAGMENT_SAFE = /[A-Za-z0-9_\-.~/:^@!,=]/; |
| 142 | |
| 143 | function 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` */ |
| 152 | export 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. */ |
| 158 | export 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:
| 1 | 1 | (() => { | |
| 2 | 2 | const CFG = window.CODE_WALK || {}; | |
| 3 | 3 | const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| 4 | 4 | const SEL_RE = /^(?:([LR])(\d+)(?:-([LR])?(\d+))?|hunk=(\d+))$/; | |
| 5 | 5 | ||
| 6 | 6 | // ---- toast ------------------------------------------------------------------ | |
| 7 | 7 | const toastEl = document.querySelector('.cw-toast'); | |
| 8 | 8 | let toastTimer; | |
| 9 | 9 | function toast(msg) { | |
| 10 | 10 | if (!toastEl) return; | |
| 11 | 11 | toastEl.textContent = msg; | |
| 12 | 12 | toastEl.classList.add('show'); | |
| 13 | 13 | clearTimeout(toastTimer); | |
| 14 | 14 | toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800); | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | async function copy(text, msg) { | |
| 18 | 18 | try { | |
| 19 | 19 | await navigator.clipboard.writeText(text); | |
| 20 | 20 | } catch { | |
| 21 | 21 | const ta = document.createElement('textarea'); | |
| 22 | 22 | ta.value = text; | |
| 23 | 23 | ta.style.position = 'fixed'; | |
| 24 | 24 | ta.style.opacity = '0'; | |
| 25 | 25 | document.body.appendChild(ta); | |
| 26 | 26 | ta.select(); | |
| 27 | 27 | document.execCommand('copy'); | |
| 28 | 28 | ta.remove(); | |
| 29 | 29 | } | |
| 30 | 30 | toast(msg); | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | // ---- live reload (keeps scroll position) ------------------------------------- | |
| 34 | 34 | const SCROLL_KEY = `cw-scroll:${location.pathname}`; | |
| 35 | 35 | try { | |
| 36 | 36 | const y = sessionStorage.getItem(SCROLL_KEY); | |
| 37 | 37 | if (y !== null) { | |
| 38 | 38 | sessionStorage.removeItem(SCROLL_KEY); | |
| 39 | 39 | history.scrollRestoration = 'manual'; | |
| 40 | 40 | window.__cwRestored = Number(y); | |
| 41 | 41 | } | |
| 42 | 42 | } catch { /* storage unavailable */ } | |
| 43 | 43 | ||
| 44 | 44 | if (window.EventSource) { | |
| 45 | 45 | const es = new EventSource('/events'); | |
| 46 | 46 | es.onmessage = (ev) => { | |
| 47 | 47 | let data = {}; | |
| 48 | 48 | try { data = JSON.parse(ev.data); } catch { return; } | |
| 49 | 49 | if (data.kind === 'comments') { | |
| 50 | 50 | if (data.walk === CFG.walk) loadComments(); | |
| 51 | 51 | return; | |
| 52 | 52 | } | |
| 53 | 53 | if (!CFG.walk || data.walk === CFG.walk) { | |
| 54 | 54 | try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch { /* ignore */ } | |
| 55 | 55 | location.reload(); | |
| 56 | 56 | } | |
| 57 | 57 | }; | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | // ---- mermaid ------------------------------------------------------------------- | |
| 61 | 61 | let layoutReady = Promise.resolve(); | |
| 62 | 62 | if (window.mermaid) { | |
| 63 | 63 | const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 64 | 64 | const css = getComputedStyle(document.documentElement); | |
| 65 | 65 | window.mermaid.initialize({ | |
| 66 | 66 | startOnLoad: false, | |
| 67 | 67 | securityLevel: 'strict', | |
| 68 | 68 | theme: dark ? 'dark' : 'neutral', | |
| 69 | 69 | fontFamily: css.getPropertyValue('--font').trim(), | |
| 70 | 70 | }); | |
| 71 | 71 | layoutReady = window.mermaid.run({ querySelector: 'pre.mermaid' }).catch((e) => console.error(e)); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // ---- folds ------------------------------------------------------------------------ | |
| 75 | 75 | function openFold(node) { | |
| 76 | 76 | const fold = node.closest('.cw-fold'); | |
| 77 | 77 | if (!fold) return; | |
| 78 | 78 | fold.classList.add('open'); | |
| 79 | 79 | fold.querySelector('.cw-expander button')?.setAttribute('aria-expanded', 'true'); | |
| 80 | 80 | } | |
| 81 | 81 | document.addEventListener('click', (e) => { | |
| 82 | 82 | const btn = e.target.closest('.cw-expander button'); | |
| 83 | 83 | if (!btn) return; | |
| 84 | 84 | const open = btn.closest('.cw-fold').classList.toggle('open'); | |
| 85 | 85 | btn.setAttribute('aria-expanded', String(open)); | |
| 86 | 86 | }); | |
| 87 | 87 | ||
| 88 | 88 | // ---- line selection --------------------------------------------------------------- | |
| 89 | 89 | let current = null; // { fig, anchor: {idx, side}, focus: {idx, side} } | |
| 90 | 90 | ||
| 91 | 91 | const rowsOf = (fig) => $$('tr.r', fig); | |
| 92 | 92 | ||
| 93 | 93 | function lineOf(row, side, mode) { | |
| 94 | 94 | const v = row.dataset[side === 'L' ? 'l' : 'r']; | |
| 95 | 95 | return v === undefined ? null : Number(v); | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | function pickSide(row, preferred, mode) { | |
| 99 | 99 | if (mode === 'file') return 'L'; | |
| 100 | 100 | if (lineOf(row, preferred) !== null) return preferred; | |
| 101 | 101 | return preferred === 'L' ? 'R' : 'L'; | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | function rangeFor(fig, a, b) { | |
| 105 | 105 | const rows = rowsOf(fig); | |
| 106 | 106 | const mode = fig.dataset.mode; | |
| 107 | 107 | let [s, e] = a.idx <= b.idx ? [a, b] : [b, a]; | |
| 108 | 108 | const sr = rows[s.idx]; | |
| 109 | 109 | const er = rows[e.idx]; | |
| 110 | 110 | if (mode === 'file') { | |
| 111 | 111 | const x = lineOf(sr, 'L'); | |
| 112 | 112 | const y = lineOf(er, 'L'); | |
| 113 | 113 | return x === y ? `L${x}` : `L${x}-${y}`; | |
| 114 | 114 | } | |
| 115 | 115 | const has = (row, side) => lineOf(row, side) !== null; | |
| 116 | 116 | let ss = s.side; | |
| 117 | 117 | let es = e.side; | |
| 118 | 118 | const valid = (p, q) => has(sr, p) && has(er, q) && !(p === 'R' && q === 'L'); | |
| 119 | 119 | if (!valid(ss, es)) { | |
| 120 | 120 | const options = [['L', 'L'], ['R', 'R'], ['L', 'R']]; | |
| 121 | 121 | const found = options.find(([p, q]) => valid(p, q)); | |
| 122 | 122 | if (found) [ss, es] = found; | |
| 123 | 123 | else { | |
| 124 | 124 | // Added lines followed by deleted lines: fall back to the new side. | |
| 125 | 125 | ss = 'R'; | |
| 126 | 126 | es = 'R'; | |
| 127 | 127 | let j = e.idx; | |
| 128 | 128 | while (j > s.idx && !has(rows[j], 'R')) j--; | |
| 129 | 129 | e = { idx: j }; | |
| 130 | 130 | } | |
| 131 | 131 | } | |
| 132 | 132 | const x = lineOf(sr, ss); | |
| 133 | 133 | const y = lineOf(rows[e.idx], es); | |
| 134 | 134 | if (ss === es) return x === y ? `${ss}${x}` : `${ss}${x}-${y}`; | |
| 135 | 135 | return `${ss}${x}-${es}${y}`; | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | 138 | /** Mirrors selectionSpan() on the server. Returns {start, end} (end exclusive) or null. */ | |
| 139 | 139 | function spanFor(fig, range) { | |
| 140 | 140 | const m = SEL_RE.exec(range); | |
| 141 | 141 | if (!m || m[5]) return null; | |
| 142 | 142 | const rows = rowsOf(fig); | |
| 143 | 143 | const mode = fig.dataset.mode; | |
| 144 | 144 | const s = { side: mode === 'file' ? 'L' : m[1], line: Number(m[2]) }; | |
| 145 | 145 | const e = m[4] ? { side: mode === 'file' ? 'L' : (m[3] || m[1]), line: Number(m[4]) } : { ...s }; | |
| 146 | 146 | let start = -1; | |
| 147 | 147 | let end = -1; | |
| 148 | 148 | if (s.side === e.side) { | |
| 149 | 149 | rows.forEach((r, i) => { | |
| 150 | 150 | const ln = lineOf(r, s.side); | |
| 151 | 151 | if (ln !== null && ln >= s.line && ln <= e.line) { | |
| 152 | 152 | if (start === -1) start = i; | |
| 153 | 153 | end = i; | |
| 154 | 154 | } | |
| 155 | 155 | }); | |
| 156 | 156 | } else { | |
| 157 | 157 | start = rows.findIndex((r) => lineOf(r, s.side) === s.line); | |
| 158 | 158 | for (let i = rows.length - 1; i >= 0; i--) if (lineOf(rows[i], e.side) === e.line) { end = i; break; } | |
| 159 | 159 | } | |
| 160 | 160 | if (start === -1 || end === -1 || end < start) return null; | |
| 161 | 161 | return { start, end: end + 1 }; | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | function clearSelection() { | |
| 165 | 165 | $$('tr.sel').forEach((r) => r.classList.remove('sel')); | |
| 166 | 166 | current = null; | |
| 167 | 167 | onSelectionChange(); | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | function paint(fig, span, { reveal = false, flash = false } = {}) { | |
| 171 | 171 | $$('tr.sel').forEach((r) => r.classList.remove('sel')); | |
| 172 | 172 | const rows = rowsOf(fig); | |
| 173 | 173 | for (let i = span.start; i < span.end; i++) { | |
| 174 | 174 | const r = rows[i]; | |
| 175 | 175 | r.classList.add('sel'); | |
| 176 | 176 | openFold(r); | |
| 177 | 177 | if (flash) { | |
| 178 | 178 | r.classList.remove('flash'); | |
| 179 | 179 | void r.offsetWidth; | |
| 180 | 180 | r.classList.add('flash'); | |
| 181 | 181 | } | |
| 182 | 182 | } | |
| 183 | 183 | onSelectionChange(); | |
| 184 | 184 | if (reveal) { | |
| 185 | 185 | const first = rows[span.start]; | |
| 186 | 186 | const rect = first.getBoundingClientRect(); | |
| 187 | 187 | const target = window.scrollY + rect.top - Math.max(80, window.innerHeight * 0.3); | |
| 188 | 188 | window.scrollTo({ top: Math.max(0, target), behavior: 'instant' in window ? 'instant' : 'auto' }); | |
| 189 | 189 | } | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | 192 | function snippetUrl(fig, range) { | |
| 193 | 193 | const frag = fig.dataset.frag + (range ? `&${range}` : ''); | |
| 194 | 194 | return `${location.origin}${location.pathname}#${frag}`; | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | function updateUrl(fig, range) { | |
| 198 | 198 | history.replaceState(null, '', `#${fig.dataset.frag}${range ? `&${range}` : ''}`); | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | 201 | document.addEventListener('click', (e) => { | |
| 202 | 202 | const cell = e.target.closest('.cw-code td.ln'); | |
| 203 | 203 | if (!cell) return; | |
| 204 | 204 | const fig = cell.closest('.cw-snippet'); | |
| 205 | 205 | const row = cell.closest('tr.r'); | |
| 206 | 206 | const rows = rowsOf(fig); | |
| 207 | 207 | const idx = rows.indexOf(row); | |
| 208 | 208 | const side = pickSide(row, cell.dataset.side, fig.dataset.mode); | |
| 209 | 209 | if (!current || current.fig !== fig || !e.shiftKey) { | |
| 210 | 210 | if (current && current.fig === fig && current.anchor.idx === idx && current.focus.idx === idx && !e.shiftKey) { | |
| 211 | 211 | clearSelection(); | |
| 212 | 212 | history.replaceState(null, '', location.pathname); | |
| 213 | 213 | return; | |
| 214 | 214 | } | |
| 215 | 215 | current = { fig, anchor: { idx, side }, focus: { idx, side } }; | |
| 216 | 216 | } else { | |
| 217 | 217 | current.focus = { idx, side }; | |
| 218 | 218 | window.getSelection()?.removeAllRanges(); | |
| 219 | 219 | } | |
| 220 | 220 | const range = rangeFor(fig, current.anchor, current.focus); | |
| 221 | 221 | const span = spanFor(fig, range); | |
| 222 | 222 | if (span) paint(fig, span); | |
| 223 | 223 | current.range = range; | |
| 224 | 224 | updateUrl(fig, range); | |
| 225 | 225 | onSelectionChange(); | |
| 226 | 226 | }); | |
| 227 | 227 | ||
| 228 | 228 | function decodeFragment(hash) { | |
| 229 | 229 | let frag = hash.replace(/^#/, ''); | |
| 230 | 230 | let range = null; | |
| 231 | 231 | const amp = frag.lastIndexOf('&'); | |
| 232 | 232 | if (amp !== -1 && SEL_RE.test(frag.slice(amp + 1))) { | |
| 233 | 233 | range = frag.slice(amp + 1); | |
| 234 | 234 | frag = frag.slice(0, amp); | |
| 235 | 235 | } | |
| 236 | 236 | let tokens; | |
| 237 | 237 | try { | |
| 238 | 238 | tokens = frag.split('+').filter(Boolean).map((t) => decodeURIComponent(t)); | |
| 239 | 239 | } catch { | |
| 240 | 240 | return null; | |
| 241 | 241 | } | |
| 242 | 242 | return { tokens, range }; | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | 245 | function findSnippet(link, range) { | |
| 246 | 246 | const strip = (s) => s.replace(/^-C \S+ /, ''); | |
| 247 | 247 | const figs = $$('.cw-snippet'); | |
| 248 | 248 | let matches = figs.filter((f) => f.dataset.link === link); | |
| 249 | 249 | if (!matches.length) matches = figs.filter((f) => strip(f.dataset.link) === strip(link)); | |
| 250 | 250 | // Prefer a snippet that already shows the lines without expanding folds. | |
| 251 | 251 | return matches.find((f) => { | |
| 252 | 252 | const span = range && spanFor(f, range); | |
| 253 | 253 | return span && rowsOf(f).slice(span.start, span.end).every((r) => !r.closest('.cw-fold')); | |
| 254 | 254 | }) || matches.find((f) => range && spanFor(f, range)) || (range ? null : matches[0]) || null; | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | 257 | function applyHash({ flash = true } = {}) { | |
| 258 | 258 | const cm = /^#comment-([0-9a-f]+)$/.exec(location.hash); | |
| 259 | 259 | if (cm) return commentsReady.then(() => focusThread(cm[1])); | |
| 260 | 260 | const hash = location.hash; | |
| 261 | 261 | if (!hash || document.getElementById(decodeURIComponent(hash.slice(1)))) return; | |
| 262 | 262 | const d = decodeFragment(hash); | |
| 263 | 263 | if (!d || !d.tokens.length || !['show', 'diff', '-C'].includes(d.tokens[0])) return; | |
| 264 | 264 | const fig = findSnippet(d.tokens.join(' '), d.range); | |
| 265 | 265 | if (!fig) { | |
| 266 | 266 | toast('That snippet isnât in this walk'); | |
| 267 | 267 | return; | |
| 268 | 268 | } | |
| 269 | 269 | if (!d.range) { | |
| 270 | 270 | fig.scrollIntoView({ block: 'start' }); | |
| 271 | 271 | return; | |
| 272 | 272 | } | |
| 273 | 273 | const span = spanFor(fig, d.range); | |
| 274 | 274 | if (!span) { | |
| 275 | 275 | toast(`Lines ${d.range} arenât shown in that snippet`); | |
| 276 | 276 | fig.scrollIntoView({ block: 'start' }); | |
| 277 | 277 | return; | |
| 278 | 278 | } | |
| 279 | 279 | const rows = rowsOf(fig); | |
| 280 | 280 | const sideOf = (r, pref) => pickSide(r, pref, fig.dataset.mode); | |
| 281 | 281 | const m = SEL_RE.exec(d.range); | |
| 282 | 282 | current = { | |
| 283 | 283 | fig, | |
| 284 | 284 | anchor: { idx: span.start, side: sideOf(rows[span.start], m[1]) }, | |
| 285 | 285 | focus: { idx: span.end - 1, side: sideOf(rows[span.end - 1], m[3] || m[1]) }, | |
| 286 | 286 | range: d.range, | |
| 287 | 287 | }; | |
| 288 | 288 | paint(fig, span, { reveal: window.__cwRestored === undefined, flash }); | |
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | 291 | window.addEventListener('hashchange', () => applyHash()); | |
| 292 | 292 | // Wait for diagrams to take their final size so scroll positions are stable. | |
| 293 | 293 | layoutReady.then(() => { | |
| 294 | 294 | if (window.__cwRestored !== undefined) window.scrollTo(0, window.__cwRestored); | |
| 295 | 295 | applyHash({ flash: window.__cwRestored === undefined }); | |
| 296 | 296 | }); | |
| 297 | 297 | ||
| 298 | 298 | // ---- copying ---------------------------------------------------------------------- | |
| 299 | 299 | function sectionTitle(fig) { | |
| 300 | 300 | let title = null; | |
| 301 | 301 | for (const h of $$('.cw-article h1, .cw-article h2, .cw-article h3')) { | |
| 302 | 302 | if (h.compareDocumentPosition(fig) & Node.DOCUMENT_POSITION_FOLLOWING) title = h.textContent.trim(); | |
| 303 | 303 | else break; | |
| 304 | 304 | } | |
| 305 | 305 | return title; | |
| 306 | 306 | } | |
| 307 | 307 | ||
| 308 | 308 | function promptText(fig, range) { | |
| 309 | 309 | const tokens = fig.dataset.link.replace(/^-C \S+ /, ''); | |
| 310 | 310 | const repo = fig.dataset.repo; | |
| 311 | 311 | const lines = range ? ` lines ${range}` : ''; | |
| 312 | 312 | const where = [ | |
| 313 | 313 | CFG.walkPath ? `walk: ${CFG.walkPath}` : null, | |
| 314 | 314 | sectionTitle(fig) ? `section "${sectionTitle(fig)}"` : null, | |
| 315 | 315 | ].filter(Boolean).join(', '); | |
| 316 | 316 | let ref; | |
| 317 | 317 | if (/^show [^:\s]+$/.test(tokens)) ref = `${fig.dataset.path}${lines} (working tree of ${repo})`; | |
| 318 | 318 | else ref = `\`git -C ${repo} ${tokens}\`${lines}${range && fig.dataset.mode === 'diff' ? ' (L = old side, R = new side)' : ''}`; | |
| 319 | 319 | return `${ref}${where ? `\n${where}` : ''}\n${snippetUrl(fig, range)}`; | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | 322 | function copyLink(fig, forPrompt) { | |
| 323 | 323 | const range = current && current.fig === fig ? current.range : null; | |
| 324 | 324 | if (forPrompt) copy(promptText(fig, range), 'Copied reference for a prompt'); | |
| 325 | 325 | else copy(snippetUrl(fig, range), range ? `Copied link to ${range}` : 'Copied link to snippet'); | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | 328 | document.addEventListener('click', (e) => { | |
| 329 | 329 | const linkBtn = e.target.closest('.cw-link-btn'); | |
| 330 | 330 | if (linkBtn) return copyLink(linkBtn.closest('.cw-snippet'), e.shiftKey); | |
| 331 | 331 | const cmdBtn = e.target.closest('.cw-copy-cmd'); | |
| 332 | 332 | if (cmdBtn) { | |
| 333 | 333 | const code = cmdBtn.parentElement.querySelector('code'); | |
| 334 | 334 | copy(code.textContent, 'Copied command'); | |
| 335 | 335 | } | |
| 336 | 336 | }); | |
| 337 | 337 | ||
| 338 | 338 | // ---- keyboard --------------------------------------------------------------------- | |
| 339 | 339 | const headings = () => $$('.cw-article h2, .cw-article h3'); | |
| 340 | 340 | ||
| 341 | 341 | document.addEventListener('keydown', (e) => { | |
| 342 | 342 | if (e.metaKey || e.ctrlKey || e.altKey) return; | |
| 343 | 343 | if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable]')) return; | |
| 344 | 344 | if (e.key === 'c') { | |
| 345 | 345 | e.preventDefault(); | |
| 346 | 346 | openComposer(); | |
| 347 | 347 | } else if (e.key === 'n' || e.key === 'p') { | |
| 348 | 348 | jumpToThread(e.key === 'n' ? 1 : -1); | |
| 349 | 349 | } else if (e.key === 'y' || e.key === 'Y') { | |
| 350 | 350 | if (!current) return toast('Click a line number to select lines first'); | |
| 351 | 351 | copyLink(current.fig, e.key === 'Y'); | |
| 352 | 352 | } else if (e.key === 'j' || e.key === 'k') { | |
| 353 | 353 | const hs = headings(); | |
| 354 | 354 | if (!hs.length) return; | |
| 355 | 355 | const y = 30; | |
| 356 | 356 | let target; | |
| 357 | 357 | if (e.key === 'j') target = hs.find((h) => h.getBoundingClientRect().top > y + 1); | |
| 358 | 358 | else target = [...hs].reverse().find((h) => h.getBoundingClientRect().top < y - 1); | |
| 359 | 359 | if (target) target.scrollIntoView({ block: 'start' }); | |
| 360 | 360 | else if (e.key === 'k') window.scrollTo({ top: 0 }); | |
| 361 | 361 | } else if (e.key === 'Escape' && current) { | |
| 362 | 362 | clearSelection(); | |
| 363 | 363 | history.replaceState(null, '', location.pathname); | |
| 364 | 364 | } | |
| 365 | 365 | }); | |
| 366 | 366 | ||
| 367 | 367 | // ---- --stat summaries: files shown elsewhere in the walk link to their snippet -------- | |
| 368 | 368 | for (const row of $$('.cw-sum-file')) { | |
| 369 | 369 | const repo = row.closest('.cw-summary').dataset.repo; | |
| 370 | 370 | const target = $$('.cw-snippet').find((f) => f.dataset.repo === repo && f.dataset.path === row.dataset.path); | |
| 371 | 371 | if (!target) continue; | |
| 372 | 372 | const label = row.querySelector('.cw-sum-path'); | |
| 373 | 373 | const btn = document.createElement('button'); | |
| 374 | 374 | btn.type = 'button'; | |
| 375 | 375 | btn.className = 'cw-sum-path'; | |
| 376 | 376 | btn.title = 'Jump to this file in the walk'; | |
| 377 | 377 | btn.textContent = label.textContent; | |
| 378 | 378 | btn.addEventListener('click', () => { | |
| 379 | 379 | target.scrollIntoView({ block: 'start' }); | |
| 380 | 380 | window.scrollBy(0, -24); | |
| 381 | 381 | target.classList.remove('flash'); | |
| 382 | 382 | void target.offsetWidth; | |
| 383 | 383 | target.classList.add('flash'); | |
| 384 | 384 | }); | |
| 385 | 385 | label.replaceWith(btn); | |
| 386 | 386 | } | |
| 387 | 387 | ||
| 388 | 388 | // ---- review comments ----------------------------------------------------------------- | |
| 389 | 389 | // Threads are stored by the server in <walk>.comments.json and anchored to a snippet's | |
| 390 | 390 | // link + line range, with a copy of the lines, so Claude can act on them via the CLI. | |
| 391 | 391 | // editBase: the text each open editor started from. Saves send it so the server can refuse to | |
| 392 | 392 | // overwrite a message that changed meanwhile (e.g. Claude edited it from the CLI). | |
| 393 | 393 | const review = { me: 'You', comments: [], drafts: new Map(), replying: new Set(), editing: new Set(), editBase: new Map(), loaded: false }; | |
| 394 | 394 | let resolveCommentsReady; | |
| 395 | 395 | const commentsReady = new Promise((r) => { resolveCommentsReady = r; }); | |
| 396 | 396 | ||
| 397 | 397 | const el = (tag, cls, html) => { | |
| 398 | 398 | const n = document.createElement(tag); | |
| 399 | 399 | if (cls) n.className = cls; | |
| 400 | 400 | if (html !== undefined) n.innerHTML = html; | |
| 401 | 401 | return n; | |
| 402 | 402 | }; | |
| 403 | 403 | const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); | |
| 404 | 404 | ||
| 405 | 405 | function ago(iso) { | |
| 406 | 406 | const s = (Date.now() - new Date(iso).getTime()) / 1000; | |
| 407 | 407 | if (s < 45) return 'just now'; | |
| 408 | 408 | if (s < 3600) return `${Math.round(s / 60)}m ago`; | |
| 409 | 409 | if (s < 86400) return `${Math.round(s / 3600)}h ago`; | |
| 410 | 410 | if (s < 86400 * 7) return `${Math.round(s / 86400)}d ago`; | |
| 411 | 411 | return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); | |
| 412 | 412 | } | |
| 413 | 413 | ||
| 414 | 414 | async function api(action, { id, body } = {}) { | |
| 415 | 415 | const q = new URLSearchParams({ walk: CFG.walk }); | |
| 416 | 416 | if (id) q.set('id', id); | |
| 417 | 417 | const res = await fetch(`/api/comments${action}?${q}`, { | |
| 418 | 418 | method: 'POST', | |
| 419 | 419 | headers: { 'content-type': 'application/json' }, | |
| 420 | 420 | body: JSON.stringify(body || {}), | |
| 421 | 421 | }); | |
| 422 | 422 | const data = await res.json().catch(() => ({})); | |
| 423 | 423 | if (!res.ok) throw Object.assign(new Error(data.error || `request failed (${res.status})`), { status: res.status, data }); | |
| 424 | 424 | return data; | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | 427 | async function loadComments() { | |
| 428 | 428 | if (!CFG.walk) return; | |
| 429 | 429 | try { | |
| 430 | 430 | const res = await fetch(`/api/comments?walk=${encodeURIComponent(CFG.walk)}`); | |
| 431 | 431 | if (!res.ok) return; | |
| 432 | 432 | const data = await res.json(); | |
| 433 | 433 | review.me = data.me || 'You'; | |
| 434 | 434 | review.comments = data.comments || []; | |
| 435 | 435 | review.loaded = true; | |
| 436 | 436 | renderThreads(); | |
| 437 | 437 | } catch (e) { | |
| 438 | 438 | console.error(e); | |
| 439 | 439 | } finally { | |
| 440 | 440 | resolveCommentsReady(); | |
| 441 | 441 | } | |
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | 444 | function anchorFor(fig, range) { | |
| 445 | 445 | const span = spanFor(fig, range); | |
| 446 | 446 | const rows = rowsOf(fig).slice(span.start, span.end); | |
| 447 | 447 | const file = fig.dataset.mode === 'file'; | |
| 448 | 448 | return { | |
| 449 | 449 | link: fig.dataset.link, | |
| 450 | 450 | range, | |
| 451 | 451 | path: fig.dataset.path, | |
| 452 | 452 | mode: fig.dataset.mode, | |
| 453 | 453 | repo: fig.dataset.repo, | |
| 454 | 454 | repoName: fig.dataset.repoName, | |
| 455 | 455 | command: fig.dataset.link, | |
| 456 | 456 | section: sectionTitle(fig), | |
| 457 | 457 | lines: rows.map((r) => ({ | |
| 458 | 458 | o: !file && r.dataset.l ? Number(r.dataset.l) : null, | |
| 459 | 459 | n: file ? Number(r.dataset.l) : r.dataset.r ? Number(r.dataset.r) : null, | |
| 460 | 460 | t: r.classList.contains('add') ? '+' : r.classList.contains('del') ? '-' : ' ', | |
| 461 | 461 | text: r.querySelector('.code').textContent, | |
| 462 | 462 | })), | |
| 463 | 463 | }; | |
| 464 | 464 | } | |
| 465 | 465 | ||
| 466 | 466 | // -- selection toolbar -- | |
| 467 | 467 | const selbar = el('div', 'cw-selbar'); | |
| 468 | 468 | selbar.setAttribute('role', 'toolbar'); | |
| 469 | 469 | document.body.appendChild(selbar); | |
| 470 | 470 | ||
| 471 | 471 | function onSelectionChange() { | |
| 472 | 472 | if (!current || !current.range || !CFG.walk) { | |
| 473 | 473 | selbar.classList.remove('show'); | |
| 474 | 474 | document.body.classList.remove('has-selbar'); | |
| 475 | 475 | return; | |
| 476 | 476 | } | |
| 477 | 477 | selbar.innerHTML = `<span class="cw-selbar-range"><code>${escHtml(current.fig.dataset.path.split('/').pop())}</code> ${escHtml(current.range)}</span>` | |
| 478 | 478 | + '<button type="button" data-act="comment">Comment <kbd>c</kbd></button>' | |
| 479 | 479 | + '<button type="button" data-act="link">Copy link <kbd>y</kbd></button>' | |
| 480 | 480 | + '<button type="button" data-act="clear" aria-label="Clear selection" title="Clear selection (Esc)">â</button>'; | |
| 481 | 481 | selbar.classList.add('show'); | |
| 482 | 482 | document.body.classList.add('has-selbar'); | |
| 483 | 483 | } | |
| 484 | 484 | ||
| 485 | 485 | selbar.addEventListener('click', (e) => { | |
| 486 | 486 | const act = e.target.closest('button')?.dataset.act; | |
| 487 | 487 | if (act === 'comment') openComposer(); | |
| 488 | 488 | else if (act === 'link' && current) copyLink(current.fig, e.shiftKey); | |
| 489 | 489 | else if (act === 'clear') { | |
| 490 | 490 | clearSelection(); | |
| 491 | 491 | history.replaceState(null, '', location.pathname); | |
| 492 | 492 | } | |
| 493 | 493 | }); | |
| 494 | 494 | ||
| 495 | 495 | // -- composer for a new comment -- | |
| 496 | 496 | let composer = null; // { fig, range, row } | |
| 497 | 497 | ||
| 498 | 498 | function closeComposer() { | |
| 499 | 499 | composer?.row.remove(); | |
| 500 | 500 | composer = null; | |
| 501 | 501 | } | |
| 502 | 502 | ||
| 503 | 503 | function openComposer() { | |
| 504 | 504 | if (!current || !current.range) return toast('Select lines first: click a line number (shift-click for a range)'); | |
| 505 | 505 | const { fig, range } = current; | |
| 506 | 506 | const span = spanFor(fig, range); | |
| 507 | 507 | if (!span) return; | |
| 508 | 508 | if (composer && composer.fig === fig && composer.range === range) { | |
| 509 | 509 | composer.row.querySelector('textarea').focus(); | |
| 510 | 510 | return; | |
| 511 | 511 | } | |
| 512 | 512 | closeComposer(); | |
| 513 | 513 | const rows = rowsOf(fig); | |
| 514 | 514 | const cols = rows[0].children.length; | |
| 515 | 515 | const row = el('tr', 'cw-comment-row cw-composer-row'); | |
| 516 | 516 | const td = el('td'); | |
| 517 | 517 | td.colSpan = cols; | |
| 518 | 518 | td.innerHTML = `<div class="cw-composer"> | |
| 519 | 519 | <div class="cw-composer-head">Comment on <b>${escHtml(range)}</b></div> | |
| 520 | 520 | <textarea rows="3" placeholder="Leave a review comment⊠(Markdown supported)"></textarea> | |
| 521 | 521 | <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> | |
| 522 | 522 | </div>`; | |
| 523 | 523 | row.appendChild(td); | |
| 524 | 524 | rows[span.end - 1].after(row); | |
| 525 | 525 | composer = { fig, range, row }; | |
| 526 | 526 | const ta = td.querySelector('textarea'); | |
| 527 | 527 | ta.value = review.drafts.get('new') || ''; | |
| 528 | 528 | ta.focus(); | |
| 529 | 529 | } | |
| 530 | 530 | ||
| 531 | 531 | async function saveComposer() { | |
| 532 | 532 | if (!composer) return; | |
| 533 | 533 | const ta = composer.row.querySelector('textarea'); | |
| 534 | 534 | const body = ta.value.trim(); | |
| 535 | 535 | if (!body) return ta.focus(); | |
| 536 | 536 | const btn = composer.row.querySelector('[data-act="save"]'); | |
| 537 | 537 | btn.disabled = true; | |
| 538 | 538 | try { | |
| 539 | 539 | const { comment } = await api('', { body: { anchor: anchorFor(composer.fig, composer.range), body } }); | |
| 540 | 540 | review.drafts.delete('new'); | |
| 541 | 541 | closeComposer(); | |
| 542 | 542 | const first = !review.comments.length; | |
| 543 | 543 | review.comments.push(comment); | |
| 544 | 544 | renderThreads(); | |
| 545 | 545 | toast(first ? 'Comment saved. When youâre done, ask Claude to âaddress my code walk commentsâ' : 'Comment saved'); | |
| 546 | 546 | } catch (e) { | |
| 547 | 547 | btn.disabled = false; | |
| 548 | 548 | toast(e.message); | |
| 549 | 549 | } | |
| 550 | 550 | } | |
| 551 | 551 | ||
| 552 | 552 | // -- thread rendering -- | |
| 553 | 553 | function avatar(name) { | |
| 554 | 554 | const claude = /^claude$/i.test(name); | |
| 555 | 555 | return `<span class="cw-avatar${claude ? ' claude' : ''}" aria-hidden="true">${claude ? 'âł' : escHtml((name || '?').trim().charAt(0).toUpperCase())}</span>`; | |
| 556 | 556 | } | |
| 557 | 557 | ||
| 558 | 558 | // A message key is the thread id, or "<thread id>/<reply id>" for a reply. | |
| 559 | 559 | function messageHtml(m, { id, isRoot }) { | |
| 560 | 560 | const key = isRoot ? id : `${id}/${m.id}`; | |
| 561 | 561 | const k = escHtml(key); | |
| 562 | 562 | const editing = review.editing.has(key); | |
| 563 | 563 | const what = !isRoot ? 'reply' : m.replies?.length ? 'thread' : 'comment'; | |
| 564 | 564 | const menu = review.confirmingDelete === key | |
| 565 | 565 | ? `<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>` | |
| 566 | 566 | : `<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>`; | |
| 567 | 567 | return `<div class="cw-msg${isRoot ? '' : ' reply'}" data-key="${k}"> | |
| 568 | 568 | ${avatar(m.author)} | |
| 569 | 569 | <div class="cw-msg-main"> | |
| 570 | 570 | <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> | |
| 571 | 571 | ${editing | |
| 572 | 572 | ? `<div class="cw-composer inline edit">${conflictHtml(key, m)}<textarea rows="1" data-draft="edit:${k}"></textarea></div>` | |
| 573 | 573 | : `<div class="cw-msg-body">${m.bodyHtml}</div>`} | |
| 574 | 574 | </div> | |
| 575 | 575 | </div>`; | |
| 576 | 576 | } | |
| 577 | 577 | ||
| 578 | 578 | /** Shown in an open editor when the message changed after editing started. */ | |
| 579 | 579 | function conflictHtml(key, m) { | |
| 580 | 580 | const base = review.editBase.get(key); | |
| 581 | 581 | if (base === undefined || base === m.body) return ''; | |
| 582 | 582 | const k = escHtml(key); | |
| 583 | 583 | return `<div class="cw-conflict" role="alert"> | |
| 584 | 584 | <div class="cw-conflict-head">This message changed while you were editing. Your text below hasnât been saved.</div> | |
| 585 | 585 | <div class="cw-conflict-label">Latest version</div> | |
| 586 | 586 | <div class="cw-conflict-current">${escHtml(m.body)}</div> | |
| 587 | 587 | <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> | |
| 588 | 588 | </div>`; | |
| 589 | 589 | } | |
| 590 | 590 | ||
| 591 | 591 | function threadEl(c, { quote = false } = {}) { | |
| 592 | 592 | const resolved = c.status === 'resolved'; | |
| 593 | 593 | const expanded = !resolved || review.expanded?.has(c.id); | |
| 594 | 594 | const node = el('div', `cw-thread${resolved ? ' resolved' : ''}${expanded ? '' : ' collapsed'}`); | |
| 595 | 595 | node.id = `comment-${c.id}`; | |
| 596 | 596 | node.dataset.id = c.id; | |
| 597 | 597 | const a = c.anchor || {}; | |
| 598 | 598 | const summary = (c.body || '').split('\n')[0].slice(0, 120); | |
| 599 | 599 | let html = ''; | |
| 600 | 600 | if (resolved) { | |
| 601 | 601 | 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>`; | |
| 602 | 602 | } | |
| 603 | 603 | if (expanded) { | |
| 604 | 604 | if (quote) { | |
| 605 | 605 | 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>`; | |
| 606 | 606 | } | |
| 607 | 607 | html += messageHtml(c, { id: c.id, isRoot: true }); | |
| 608 | 608 | for (const r of c.replies) html += messageHtml(r, { id: c.id, isRoot: false }); | |
| 609 | 609 | // While a message is being edited, its Cancel / Save take the place of Reply / Resolve. | |
| 610 | 610 | const editKey = [...review.editing].find((key) => key === c.id || key.startsWith(`${c.id}/`)); | |
| 611 | 611 | if (editKey) { | |
| 612 | 612 | node.classList.add('editing'); | |
| 613 | 613 | const k = escHtml(editKey); | |
| 614 | 614 | 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>`; | |
| 615 | 615 | } else if (review.replying.has(c.id)) { | |
| 616 | 616 | 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>`; | |
| 617 | 617 | } else { | |
| 618 | 618 | 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>`; | |
| 619 | 619 | } | |
| 620 | 620 | } | |
| 621 | 621 | node.innerHTML = html; | |
| 622 | 622 | return node; | |
| 623 | 623 | } | |
| 624 | 624 | ||
| 625 | 625 | function renderThreads() { | |
| 626 | 626 | // Keep drafts and focus across re-renders (e.g. when Claude replies from the CLI). | |
| 627 | 627 | const active = document.activeElement; | |
| 628 | 628 | const activeKey = active?.dataset?.draft; | |
| 629 | 629 | const caret = activeKey ? [active.selectionStart, active.selectionEnd] : null; | |
| 630 | 630 | $$('textarea[data-draft]').forEach((t) => review.drafts.set(t.dataset.draft, t.value)); | |
| 631 | 631 | if (composer) review.drafts.set('new', composer.row.querySelector('textarea').value); | |
| 632 | 632 | ||
| 633 | 633 | $$('.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')); | |
| 635 | 635 | ||
| 636 | 636 | const groups = new Map(); | |
| 637 | 637 | const unplaced = []; | |
| 638 | 638 | for (const c of review.comments) { | |
| 639 | 639 | const a = c.anchor; | |
| 640 | 640 | const fig = a && a.link && a.range ? findSnippet(a.link, a.range) : null; | |
| 641 | 641 | const span = fig && spanFor(fig, a.range); | |
| 642 | 642 | if (!span) { unplaced.push(c); continue; } | |
| 643 | 643 | const rows = rowsOf(fig); | |
| 644 | 644 | const endRow = rows[span.end - 1]; | |
| 645 | 645 | if (!groups.has(endRow)) groups.set(endRow, []); | |
| 646 | 646 | groups.get(endRow).push(c); | |
| 647 | 647 | for (let i = span.start; i < span.end; i++) { | |
| 648 | 648 | const r = rows[i]; | |
| 649 | 649 | if (c.status !== 'resolved') { | |
| 650 | 650 | r.classList.add('commented'); | |
| 651 | 651 | r.classList.remove('resolved-only'); | |
| 652 | 652 | openFold(r); | |
| 653 | 653 | } else if (!r.classList.contains('commented')) { | |
| 654 | 654 | r.classList.add('commented', 'resolved-only'); | |
| 655 | 655 | } | |
| 656 | 656 | } | |
| 657 | + | rows[span.start].classList.add('cm-start'); | |
| 658 | + | rows[span.end - 1].classList.add('cm-end'); | |
| 657 | 659 | } | |
| 658 | 660 | for (const [endRow, list] of groups) { | |
| 659 | 661 | const tr = el('tr', 'cw-comment-row cw-thread-row'); | |
| 660 | 662 | const td = el('td'); | |
| 661 | 663 | td.colSpan = endRow.children.length; | |
| 662 | 664 | const wrap = el('div', 'cw-threads'); | |
| 663 | 665 | list.forEach((c) => wrap.appendChild(threadEl(c))); | |
| 664 | 666 | td.appendChild(wrap); | |
| 665 | 667 | tr.appendChild(td); | |
| 666 | 668 | endRow.after(tr); | |
| 667 | 669 | } | |
| 668 | 670 | if (unplaced.length) { | |
| 669 | 671 | const sec = el('section', 'cw-unplaced'); | |
| 670 | 672 | sec.innerHTML = '<h2>Comments on code no longer in this walk</h2>'; | |
| 671 | 673 | unplaced.forEach((c) => sec.appendChild(threadEl(c, { quote: true }))); | |
| 672 | 674 | document.querySelector('.cw-article')?.appendChild(sec); | |
| 673 | 675 | } | |
| 674 | 676 | ||
| 675 | 677 | for (const [key, value] of review.drafts) { | |
| 676 | 678 | const t = document.querySelector(`textarea[data-draft="${CSS.escape(key)}"]`); | |
| 677 | 679 | if (t) t.value = value; | |
| 678 | 680 | } | |
| 679 | 681 | for (const key of review.editing) { | |
| 680 | 682 | const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 681 | 683 | const m = messageFor(key); | |
| 682 | 684 | if (t && !review.drafts.has(`edit:${key}`) && m) t.value = m.body; | |
| 683 | 685 | } | |
| 684 | 686 | $$('.cw-composer textarea').forEach(autosize); | |
| 685 | 687 | if (activeKey) { | |
| 686 | 688 | const t = document.querySelector(`textarea[data-draft="${CSS.escape(activeKey)}"]`); | |
| 687 | 689 | if (t) { t.focus({ preventScroll: true }); if (caret) t.setSelectionRange(caret[0], caret[1]); } | |
| 688 | 690 | } | |
| 689 | 691 | } | |
| 690 | 692 | ||
| 691 | 693 | /** Grow a textarea to fit its content so the whole comment is visible while editing. */ | |
| 692 | 694 | function autosize(t) { | |
| 693 | 695 | t.style.height = 'auto'; | |
| 694 | 696 | t.style.height = `${t.scrollHeight + (t.offsetHeight - t.clientHeight)}px`; | |
| 695 | 697 | } | |
| 696 | 698 | ||
| 697 | 699 | function messageFor(key) { | |
| 698 | 700 | const [id, replyId] = key.split('/'); | |
| 699 | 701 | const c = review.comments.find((x) => x.id === id); | |
| 700 | 702 | return c && (replyId ? c.replies.find((r) => r.id === replyId) : c); | |
| 701 | 703 | } | |
| 702 | 704 | ||
| 703 | 705 | // -- thread actions -- | |
| 704 | 706 | document.addEventListener('click', async (e) => { | |
| 705 | 707 | const btn = e.target.closest('.cw-thread button, .cw-composer-row button'); | |
| 706 | 708 | if (!btn) return; | |
| 707 | 709 | const act = btn.dataset.act; | |
| 708 | 710 | if (act === 'cancel') { review.drafts.delete('new'); return closeComposer(); } | |
| 709 | 711 | if (act === 'save') return saveComposer(); | |
| 710 | 712 | const thread = btn.closest('.cw-thread'); | |
| 711 | 713 | if (!thread) return; | |
| 712 | 714 | const id = thread.dataset.id; | |
| 713 | 715 | const c = review.comments.find((x) => x.id === id); | |
| 714 | 716 | const replace = (updated) => { | |
| 715 | 717 | const i = review.comments.findIndex((x) => x.id === id); | |
| 716 | 718 | if (i !== -1) review.comments[i] = updated; | |
| 717 | 719 | }; | |
| 718 | 720 | try { | |
| 719 | 721 | if (act === 'toggle') { | |
| 720 | 722 | review.expanded ??= new Set(); | |
| 721 | 723 | if (review.expanded.has(id)) review.expanded.delete(id); else review.expanded.add(id); | |
| 722 | 724 | renderThreads(); | |
| 723 | 725 | } else if (act === 'reply') { | |
| 724 | 726 | review.replying.add(id); | |
| 725 | 727 | renderThreads(); | |
| 726 | 728 | document.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`)?.focus(); | |
| 727 | 729 | } else if (act === 'cancel-reply') { | |
| 728 | 730 | review.replying.delete(id); | |
| 729 | 731 | review.drafts.delete(`reply:${id}`); | |
| 730 | 732 | renderThreads(); | |
| 731 | 733 | } else if (act === 'send-reply') { | |
| 732 | 734 | const t = thread.querySelector(`textarea[data-draft="reply:${CSS.escape(id)}"]`); | |
| 733 | 735 | if (!t.value.trim()) return t.focus(); | |
| 734 | 736 | btn.disabled = true; | |
| 735 | 737 | const { comment } = await api('/reply', { id, body: { body: t.value } }); | |
| 736 | 738 | review.replying.delete(id); | |
| 737 | 739 | review.drafts.delete(`reply:${id}`); | |
| 738 | 740 | replace(comment); | |
| 739 | 741 | renderThreads(); | |
| 740 | 742 | } else if (act === 'resolve' || act === 'reopen') { | |
| 741 | 743 | const { comment } = await api('/status', { id, body: { status: act === 'resolve' ? 'resolved' : 'open' } }); | |
| 742 | 744 | replace(comment); | |
| 743 | 745 | review.expanded?.delete(id); | |
| 744 | 746 | renderThreads(); | |
| 745 | 747 | toast(act === 'resolve' ? 'Resolved' : 'Reopened'); | |
| 746 | 748 | } else if (act === 'edit') { | |
| 747 | 749 | const key = btn.dataset.key; | |
| 748 | 750 | // One message per thread at a time, since the thread's action row holds its Save / Cancel. | |
| 749 | 751 | for (const other of [...review.editing]) if (other.split('/')[0] === id) review.editing.delete(other); | |
| 750 | 752 | review.replying.delete(id); | |
| 751 | 753 | review.editing.add(key); | |
| 752 | 754 | review.editBase.set(key, messageFor(key)?.body ?? ''); | |
| 753 | 755 | renderThreads(); | |
| 754 | 756 | const t = document.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 755 | 757 | // Keep the page still: the editor takes the text's place and shows all of it. | |
| 756 | 758 | if (t) { t.focus({ preventScroll: true }); t.setSelectionRange(t.value.length, t.value.length); } | |
| 757 | 759 | } else if (act === 'cancel-edit') { | |
| 758 | 760 | const key = btn.dataset.key; | |
| 759 | 761 | review.editing.delete(key); | |
| 760 | 762 | review.editBase.delete(key); | |
| 761 | 763 | review.drafts.delete(`edit:${key}`); | |
| 762 | 764 | renderThreads(); | |
| 763 | 765 | } else if (act === 'keep-mine') { | |
| 764 | 766 | // The user has now seen the latest text, so it becomes the base for their save. | |
| 765 | 767 | const key = btn.dataset.key; | |
| 766 | 768 | review.editBase.set(key, messageFor(key)?.body ?? ''); | |
| 767 | 769 | thread.querySelector(`[data-act="save-edit"][data-key="${CSS.escape(key)}"]`)?.click(); | |
| 768 | 770 | } else if (act === 'take-latest') { | |
| 769 | 771 | const key = btn.dataset.key; | |
| 770 | 772 | const latest = messageFor(key)?.body ?? ''; | |
| 771 | 773 | review.editBase.set(key, latest); | |
| 772 | 774 | review.drafts.set(`edit:${key}`, latest); | |
| 773 | 775 | thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`).value = latest; | |
| 774 | 776 | renderThreads(); | |
| 775 | 777 | } else if (act === 'save-edit') { | |
| 776 | 778 | const key = btn.dataset.key; | |
| 777 | 779 | const t = thread.querySelector(`textarea[data-draft="edit:${CSS.escape(key)}"]`); | |
| 778 | 780 | if (!t.value.trim()) return t.focus(); | |
| 779 | 781 | btn.disabled = true; | |
| 780 | 782 | try { | |
| 781 | 783 | const { comment } = await api('/edit', { id, body: { body: t.value, reply: key.split('/')[1] || null, base: review.editBase.get(key) } }); | |
| 782 | 784 | review.editing.delete(key); | |
| 783 | 785 | review.editBase.delete(key); | |
| 784 | 786 | review.drafts.delete(`edit:${key}`); | |
| 785 | 787 | replace(comment); | |
| 786 | 788 | } catch (err) { | |
| 787 | 789 | if (err.status !== 409) throw err; | |
| 788 | 790 | // Someone else changed it: keep the draft, show their latest text, let the user choose. | |
| 789 | 791 | const m = messageFor(key); | |
| 790 | 792 | if (m) m.body = err.data.current; | |
| 791 | 793 | toast('Not saved: this message changed while you were editing'); | |
| 792 | 794 | } | |
| 793 | 795 | renderThreads(); | |
| 794 | 796 | } else if (act === 'delete') { | |
| 795 | 797 | // Inline confirmation: native confirm() is blocked in some embedded browsers. | |
| 796 | 798 | review.confirmingDelete = btn.dataset.key; | |
| 797 | 799 | renderThreads(); | |
| 798 | 800 | document.querySelector(`.cw-msg[data-key="${CSS.escape(btn.dataset.key)}"] [data-act="confirm-delete"]`)?.focus(); | |
| 799 | 801 | } else if (act === 'cancel-delete') { | |
| 800 | 802 | review.confirmingDelete = null; | |
| 801 | 803 | renderThreads(); | |
| 802 | 804 | } else if (act === 'confirm-delete') { | |
| 803 | 805 | const replyId = btn.dataset.key.split('/')[1] || null; | |
| 804 | 806 | btn.disabled = true; | |
| 805 | 807 | const { comment } = await api('/delete', { id, body: { reply: replyId } }); | |
| 806 | 808 | review.confirmingDelete = null; | |
| 807 | 809 | if (replyId) replace(comment); | |
| 808 | 810 | else review.comments = review.comments.filter((x) => x.id !== id); | |
| 809 | 811 | renderThreads(); | |
| 810 | 812 | toast(replyId ? 'Reply deleted' : c && c.replies.length ? 'Thread deleted' : 'Comment deleted'); | |
| 811 | 813 | } else if (act === 'copy-link') { | |
| 812 | 814 | copy(`${location.origin}${location.pathname}#comment-${id}`, 'Copied link to comment'); | |
| 813 | 815 | } | |
| 814 | 816 | } catch (err) { | |
| 815 | 817 | btn.disabled = false; | |
| 816 | 818 | toast(err.message); | |
| 817 | 819 | } | |
| 818 | 820 | }); | |
| 819 | 821 | ||
| 820 | 822 | document.addEventListener('keydown', (e) => { | |
| 821 | 823 | const t = e.target; | |
| 822 | 824 | if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return; | |
| 823 | 825 | // Edit boxes keep their buttons in the thread's action row; other composers inline. | |
| 824 | 826 | const scope = t.closest('.cw-composer.edit') ? t.closest('.cw-thread') : t.closest('.cw-composer'); | |
| 825 | 827 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { | |
| 826 | 828 | e.preventDefault(); | |
| 827 | 829 | scope.querySelector('[data-act="save-edit"], .cw-btn.primary')?.click(); | |
| 828 | 830 | } else if (e.key === 'Escape') { | |
| 829 | 831 | e.preventDefault(); | |
| 830 | 832 | scope.querySelector('[data-act="cancel-edit"], .cw-btn:not(.primary)')?.click(); | |
| 831 | 833 | } | |
| 832 | 834 | }); | |
| 833 | 835 | ||
| 834 | 836 | document.addEventListener('input', (e) => { | |
| 835 | 837 | const t = e.target; | |
| 836 | 838 | if (!(t instanceof HTMLTextAreaElement) || !t.closest('.cw-composer')) return; | |
| 837 | 839 | autosize(t); | |
| 838 | 840 | if (t.closest('.cw-composer-row')) review.drafts.set('new', t.value); | |
| 839 | 841 | }); | |
| 840 | 842 | ||
| 841 | 843 | // -- navigation between open threads (n / p) -- | |
| 842 | 844 | function openThreads() { | |
| 843 | 845 | return $$('.cw-thread').filter((t) => !t.classList.contains('resolved')); | |
| 844 | 846 | } | |
| 845 | 847 | ||
| 846 | 848 | let threadCursor = -1; | |
| 847 | 849 | function jumpToThread(dir) { | |
| 848 | 850 | const threads = openThreads(); | |
| 849 | 851 | if (!threads.length) return toast(review.comments.length ? 'No open comments' : 'No comments yet â select lines and press c'); | |
| 850 | 852 | const y = window.innerHeight * 0.3; | |
| 851 | 853 | if (dir > 0) threadCursor = threads.findIndex((t) => t.getBoundingClientRect().top > y + 4); | |
| 852 | 854 | else threadCursor = threads.map((t) => t.getBoundingClientRect().top < y - 4).lastIndexOf(true); | |
| 853 | 855 | if (threadCursor === -1) threadCursor = dir > 0 ? 0 : threads.length - 1; | |
| 854 | 856 | focusThread(threads[threadCursor].dataset.id); | |
| 855 | 857 | } | |
| 856 | 858 | ||
| 857 | 859 | function focusThread(id) { | |
| 858 | 860 | let node = document.getElementById(`comment-${id}`); | |
| 859 | 861 | if (!node) { | |
| 860 | 862 | const hit = review.comments.find((c) => c.id.startsWith(id)); | |
| 861 | 863 | node = hit && document.getElementById(`comment-${hit.id}`); | |
| 862 | 864 | } | |
| 863 | 865 | if (!node) return toast('That comment isnât in this walk'); | |
| 864 | 866 | openFold(node); | |
| 865 | 867 | const top = window.scrollY + node.getBoundingClientRect().top - window.innerHeight * 0.3; | |
| 866 | 868 | window.scrollTo({ top: Math.max(0, top) }); | |
| 867 | 869 | node.classList.remove('flash'); | |
| 868 | 870 | void node.offsetWidth; | |
| 869 | 871 | node.classList.add('flash'); | |
| 870 | 872 | } | |
| 871 | 873 | ||
| 872 | 874 | loadComments(); | |
| 873 | 875 | ||
| 874 | 876 | // ---- TOC active section ------------------------------------------------------------- | |
| 875 | 877 | const tocLinks = $$('.cw-toc a'); | |
| 876 | 878 | if (tocLinks.length) { | |
| 877 | 879 | const byId = new Map(tocLinks.map((a) => [a.dataset.id, a])); | |
| 878 | 880 | const hs = $$('.cw-article h2, .cw-article h3').filter((h) => byId.has(h.id)); | |
| 879 | 881 | const update = () => { | |
| 880 | 882 | // Above the first heading, the title link at the top of the sidebar is the active one. | |
| 881 | 883 | let active = null; | |
| 882 | 884 | for (const h of hs) if (h.getBoundingClientRect().top < window.innerHeight * 0.25) active = h; | |
| 883 | 885 | const id = active ? active.id : ''; | |
| 884 | 886 | tocLinks.forEach((a) => a.classList.toggle('active', a.dataset.id === id)); | |
| 885 | 887 | }; | |
| 886 | 888 | tocLinks[0].addEventListener('click', (e) => { | |
| 887 | 889 | e.preventDefault(); | |
| 888 | 890 | window.scrollTo({ top: 0 }); | |
| 889 | 891 | history.replaceState(null, '', location.pathname); | |
| 890 | 892 | }); | |
| 891 | 893 | window.addEventListener('scroll', update, { passive: true }); | |
| 892 | 894 | update(); | |
| 893 | 895 | } | |
| 894 | 896 | })(); | |
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