repoview 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/CONTRIBUTING.md +4 -3
  3. package/DEVELOPMENT.md +70 -16
  4. package/README.md +36 -5
  5. package/dist/api.js +58 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.js +224 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/csv.js +64 -0
  10. package/dist/csv.js.map +1 -0
  11. package/dist/format.js +25 -0
  12. package/dist/format.js.map +1 -0
  13. package/dist/git.js +67 -0
  14. package/dist/git.js.map +1 -0
  15. package/dist/gitignore.js +34 -0
  16. package/dist/gitignore.js.map +1 -0
  17. package/dist/linkcheck.js +310 -0
  18. package/dist/linkcheck.js.map +1 -0
  19. package/dist/markdown.js +493 -0
  20. package/dist/markdown.js.map +1 -0
  21. package/dist/net.js +10 -0
  22. package/dist/net.js.map +1 -0
  23. package/dist/paths.js +59 -0
  24. package/dist/paths.js.map +1 -0
  25. package/dist/reload.js +36 -0
  26. package/dist/reload.js.map +1 -0
  27. package/dist/repo-context.js +73 -0
  28. package/dist/repo-context.js.map +1 -0
  29. package/dist/repo-router.js +801 -0
  30. package/dist/repo-router.js.map +1 -0
  31. package/dist/review-cli.js +228 -0
  32. package/dist/review-cli.js.map +1 -0
  33. package/dist/server.js +116 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/session.js +86 -0
  36. package/dist/session.js.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/views.js +633 -0
  40. package/dist/views.js.map +1 -0
  41. package/package.json +20 -9
  42. package/public/app.css +82 -0
  43. package/public/app.js +4 -2
  44. package/public/review.js +9 -6
  45. package/public/session.js +61 -0
  46. package/src/cli.js +0 -91
  47. package/src/gitignore.js +0 -34
  48. package/src/linkcheck.js +0 -312
  49. package/src/markdown.js +0 -518
  50. package/src/review-cli.js +0 -245
  51. package/src/server.js +0 -1126
  52. package/src/views.js +0 -657
package/dist/views.js ADDED
@@ -0,0 +1,633 @@
1
+ import path from "node:path";
2
+ function renderRepoSwitcher(repos, currentRepoId, repoName) {
3
+ if (repos.length < 1)
4
+ return "";
5
+ const current = repos.find((r) => r.id === currentRepoId);
6
+ const currentName = current ? current.name : repoName;
7
+ return `<details class="repo-switcher">
8
+ <summary class="pill link">${escapeHtml(currentName)} ▾</summary>
9
+ <div class="menu-panel" role="menu">
10
+ ${repos
11
+ .map((r) => `<a class="menu-item link${r.id === currentRepoId ? " current" : ""}" role="menuitem" href="/r/${encodeURIComponent(r.id)}/tree/">${escapeHtml(r.name)}</a>`)
12
+ .join("")}
13
+ <a class="menu-item link manage" role="menuitem" href="/session">⚙ Manage repos…</a>
14
+ </div>
15
+ </details>`;
16
+ }
17
+ export function renderSessionPage({ repos, version, notice, canManage = true, }) {
18
+ const actionCol = canManage ? "<th></th>" : "";
19
+ const colspan = canManage ? 4 : 3;
20
+ const rows = repos.length
21
+ ? repos
22
+ .map((r) => `<tr>
23
+ <td><a class="link" href="/r/${encodeURIComponent(r.id)}/tree/">${escapeHtml(r.name)}</a></td>
24
+ <td><span class="pill mono">${escapeHtml(r.branch || "no-git")}</span></td>
25
+ <td class="session-path mono">${escapeHtml(r.path)}</td>
26
+ ${canManage ? `<td><button class="btn btn-sm repo-remove" data-id="${escapeHtml(r.id)}">Remove</button></td>` : ""}
27
+ </tr>`)
28
+ .join("")
29
+ : `<tr><td colspan="${colspan}" class="muted">No repositories registered.${canManage ? " Add one below." : ""}</td></tr>`;
30
+ const addCard = canManage
31
+ ? `<div class="card">
32
+ <h2 class="card-title">Add a repository</h2>
33
+ <form id="add-repo-form" class="session-add">
34
+ <input id="add-repo-path" type="text" placeholder="/absolute/path/to/repo" autocomplete="off" />
35
+ <button class="btn" type="submit">Add</button>
36
+ </form>
37
+ <p class="muted" id="add-repo-error"></p>
38
+ </div>`
39
+ : `<p class="muted">Repositories can only be added or removed from the host machine (localhost).</p>`;
40
+ return `<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
45
+ <title>repoview · session</title>
46
+ <link rel="stylesheet" href="/static/app.css" />
47
+ </head>
48
+ <body data-repo-base="">
49
+ <header class="topbar">
50
+ <div class="topbar-row">
51
+ <a class="brand" href="/session">repoview</a>
52
+ <div class="meta"><span class="pill">session</span></div>
53
+ </div>
54
+ </header>
55
+ <main class="container">
56
+ ${notice ? `<div class="note">${escapeHtml(notice)}</div>` : ""}
57
+ <div class="card">
58
+ <h2 class="card-title">Repositories</h2>
59
+ <table class="session-table">
60
+ <thead><tr><th>Name</th><th>Branch</th><th>Path</th>${actionCol}</tr></thead>
61
+ <tbody id="repo-rows">${rows}</tbody>
62
+ </table>
63
+ </div>
64
+ ${addCard}
65
+ ${version ? `<p class="muted">repoview v${escapeHtml(version)}</p>` : ""}
66
+ </main>
67
+ ${canManage ? `<script type="module" src="/static/session.js"></script>` : ""}
68
+ </body>
69
+ </html>`;
70
+ }
71
+ function formatReviewTime(isoString) {
72
+ if (!isoString)
73
+ return "";
74
+ const d = new Date(isoString);
75
+ const now = Date.now();
76
+ const diff = now - d.getTime();
77
+ if (diff < 60000)
78
+ return "just now";
79
+ if (diff < 3600000)
80
+ return `${Math.floor(diff / 60000)}m ago`;
81
+ if (diff < 86400000)
82
+ return `${Math.floor(diff / 3600000)}h ago`;
83
+ if (diff < 604800000)
84
+ return `${Math.floor(diff / 86400000)}d ago`;
85
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
86
+ }
87
+ export function escapeHtml(s) {
88
+ return String(s)
89
+ .replaceAll("&", "&amp;")
90
+ .replaceAll("<", "&lt;")
91
+ .replaceAll(">", "&gt;")
92
+ .replaceAll('"', "&quot;")
93
+ .replaceAll("'", "&#39;");
94
+ }
95
+ function encodePathForUrl(posixPath) {
96
+ return posixPath
97
+ .split("/")
98
+ .map((segment) => encodeURIComponent(segment))
99
+ .join("/");
100
+ }
101
+ function renderBreadcrumbs(relPathPosix, querySuffix, repoBase) {
102
+ const parts = (relPathPosix || "").split("/").filter(Boolean);
103
+ const suffix = querySuffix || "";
104
+ const crumbs = [{ name: "", href: `${repoBase}/tree/${suffix}` }];
105
+ let cursor = "";
106
+ for (const p of parts) {
107
+ cursor = cursor ? `${cursor}/${p}` : p;
108
+ crumbs.push({ name: p, href: `${repoBase}/tree/${encodePathForUrl(cursor)}${suffix}` });
109
+ }
110
+ const html = crumbs
111
+ .map((c, idx) => {
112
+ const label = idx === 0 ? "root" : escapeHtml(c.name);
113
+ return `<a class="crumb" href="${c.href}">${label}</a>`;
114
+ })
115
+ .join(`<span class="crumb-sep">/</span>`);
116
+ return `<nav class="breadcrumbs" aria-label="Breadcrumbs">${html}</nav>`;
117
+ }
118
+ function pageTemplate({ title, repoName, gitInfo, relPathPosix, bodyHtml, repoBase, repos, currentRepoId, }) {
119
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
120
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
121
+ const repoSwitcher = renderRepoSwitcher(repos, currentRepoId, repoName);
122
+ return `<!doctype html>
123
+ <html lang="en">
124
+ <head>
125
+ <meta charset="utf-8" />
126
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
127
+ <title>${escapeHtml(title)}</title>
128
+ <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
129
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
130
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
131
+ <link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
132
+ <link rel="stylesheet" href="/static/app.css" />
133
+ </head>
134
+ <body data-repo-base="${escapeHtml(repoBase)}">
135
+ <header class="topbar">
136
+ <div class="topbar-row">
137
+ <a class="brand" href="${repoBase}/tree/">${escapeHtml(repoName)}</a>
138
+ ${repoSwitcher}
139
+ <div class="meta">
140
+ <span class="pill">${branch}</span>
141
+ ${commit ? `<span class="pill mono">${commit}</span>` : ""}
142
+ </div>
143
+ </div>
144
+ ${renderBreadcrumbs(relPathPosix, "", repoBase)}
145
+ </header>
146
+ <main class="container">
147
+ ${bodyHtml}
148
+ </main>
149
+ <script defer src="/static/vendor/katex/katex.min.js"></script>
150
+ <script defer src="/static/vendor/katex/contrib/auto-render.min.js"></script>
151
+ <script type="module" src="/static/app.js"></script>
152
+ </body>
153
+ </html>`;
154
+ }
155
+ function renderBrokenLinksPill(brokenLinks, querySuffix, repoBase) {
156
+ const state = brokenLinks;
157
+ if (!state)
158
+ return "";
159
+ const status = state.status;
160
+ const count = state.lastResult?.broken?.length ?? 0;
161
+ const href = `${repoBase}/broken-links${querySuffix || ""}`;
162
+ if (status === "running")
163
+ return `<a class="pill link" href="${href}">Scanning links…</a>`;
164
+ if (state.lastResult) {
165
+ return `<a class="pill link" href="${href}">Broken: ${count}</a>`;
166
+ }
167
+ if (state.lastError)
168
+ return `<a class="pill link" href="${href}">Broken: ?</a>`;
169
+ return "";
170
+ }
171
+ function renderIgnoredTogglePill({ toggleIgnoredHref, showIgnored }) {
172
+ const href = toggleIgnoredHref || "#";
173
+ const label = showIgnored ? "Ignored: on" : "Ignored: off";
174
+ return `<a class="pill link" data-no-preserve="ignored" href="${href}">${label}</a>`;
175
+ }
176
+ function renderMetaMenu({ gitInfo, brokenLinks, querySuffix, toggleIgnoredHref, showIgnored, repoBase, }) {
177
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
178
+ const brokenState = brokenLinks;
179
+ const brokenCount = brokenState?.lastResult?.broken?.length ?? null;
180
+ const brokenLabel = brokenState?.status === "running"
181
+ ? "Broken links: scanning…"
182
+ : brokenCount == null
183
+ ? "Broken links"
184
+ : `Broken links: ${brokenCount}`;
185
+ const brokenHref = `${repoBase}/broken-links${querySuffix || ""}`;
186
+ const ignoredHref = toggleIgnoredHref || "#";
187
+ const ignoredLabel = showIgnored ? "Hide ignored files" : "Show ignored files";
188
+ const diffHref = `${repoBase}/diff${querySuffix || ""}`;
189
+ return `<details class="meta-menu">
190
+ <summary class="pill link" aria-label="More">More</summary>
191
+ <div class="menu-panel" role="menu">
192
+ <a class="menu-item link" href="${diffHref}" role="menuitem">Diff view</a>
193
+ <a class="menu-item link" href="${repoBase}/review/" role="menuitem">Reviews</a>
194
+ <a class="menu-item link" href="${brokenHref}" role="menuitem">${escapeHtml(brokenLabel)}</a>
195
+ <a class="menu-item link" data-no-preserve="ignored" href="${ignoredHref}" role="menuitem">${escapeHtml(ignoredLabel)}</a>
196
+ ${commit ? `<div class="menu-item mono" role="menuitem">Commit: ${commit}</div>` : ""}
197
+ </div>
198
+ </details>`;
199
+ }
200
+ function pageTemplateWithLinks({ title, repoName, gitInfo, relPathPosix, bodyHtml, brokenLinks, querySuffix, toggleIgnoredHref, showIgnored, repoBase, repos, currentRepoId, }) {
201
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
202
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
203
+ const brokenPill = renderBrokenLinksPill(brokenLinks, querySuffix, repoBase);
204
+ const ignoredPill = renderIgnoredTogglePill({ toggleIgnoredHref, showIgnored });
205
+ const metaMenu = renderMetaMenu({ gitInfo, brokenLinks, querySuffix, toggleIgnoredHref, showIgnored, repoBase });
206
+ const repoSwitcher = renderRepoSwitcher(repos, currentRepoId, repoName);
207
+ return `<!doctype html>
208
+ <html lang="en">
209
+ <head>
210
+ <meta charset="utf-8" />
211
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
212
+ <title>${escapeHtml(title)}</title>
213
+ <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
214
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
215
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
216
+ <link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
217
+ <link rel="stylesheet" href="/static/app.css" />
218
+ </head>
219
+ <body data-repo-base="${escapeHtml(repoBase)}">
220
+ <header class="topbar">
221
+ <div class="topbar-row">
222
+ <a class="brand" href="${repoBase}/tree/${querySuffix || ""}">${escapeHtml(repoName)}</a>
223
+ ${repoSwitcher}
224
+ <div class="meta">
225
+ <span class="pill">${branch}</span>
226
+ ${commit ? `<span class="pill mono meta-commit">${commit}</span>` : ""}
227
+ <span class="meta-actions">
228
+ <a class="pill link" href="${repoBase}/diff${querySuffix || ""}">Diff</a>
229
+ <a class="pill link" href="${repoBase}/review/">Review</a>
230
+ ${brokenPill}
231
+ ${ignoredPill}
232
+ </span>
233
+ <span id="conn-status" class="conn-status" title="Live reload: connecting..."></span>
234
+ ${metaMenu}
235
+ </div>
236
+ </div>
237
+ ${renderBreadcrumbs(relPathPosix, querySuffix, repoBase)}
238
+ </header>
239
+ <main class="container">
240
+ ${bodyHtml}
241
+ </main>
242
+ <script defer src="/static/vendor/katex/katex.min.js"></script>
243
+ <script defer src="/static/vendor/katex/contrib/auto-render.min.js"></script>
244
+ <script type="module" src="/static/app.js"></script>
245
+ </body>
246
+ </html>`;
247
+ }
248
+ export function renderTreePage({ title, repoName, gitInfo, brokenLinks, querySuffix, toggleIgnoredHref, showIgnored, relPathPosix, rows, readmeHtml, repoBase, repos, currentRepoId, }) {
249
+ const tableRows = rows
250
+ .map((r) => {
251
+ const icon = r.isDir ? "dir" : "file";
252
+ const name = escapeHtml(r.name);
253
+ const tsAttr = r.mtimeMs ? ` data-ts="${r.mtimeMs}"` : "";
254
+ return `<tr>
255
+ <td class="name"><a class="item ${icon}" href="${r.href}">${name}</a></td>
256
+ <td class="mtime"${tsAttr}>${escapeHtml(r.mtime)}</td>
257
+ <td class="size">${escapeHtml(r.size)}</td>
258
+ </tr>`;
259
+ })
260
+ .join("\n");
261
+ const readmeSection = readmeHtml
262
+ ? `<section class="panel readme">
263
+ <div class="panel-title">README</div>
264
+ <div class="markdown-body markdown-wrap">${readmeHtml}</div>
265
+ </section>`
266
+ : "";
267
+ const body = `<section class="panel">
268
+ <div class="panel-title">Files</div>
269
+ <div class="table-wrap">
270
+ <table class="file-table">
271
+ <thead>
272
+ <tr><th class="name">Name</th><th class="mtime">Last modified <button type="button" class="tz-toggle" title="Toggle local/UTC time">Local</button></th><th class="size">Size</th></tr>
273
+ </thead>
274
+ <tbody>
275
+ ${tableRows || `<tr><td colspan="3" class="empty">Empty directory</td></tr>`}
276
+ </tbody>
277
+ </table>
278
+ </div>
279
+ </section>
280
+ ${readmeSection}`;
281
+ return pageTemplateWithLinks({
282
+ title,
283
+ repoName,
284
+ gitInfo,
285
+ brokenLinks,
286
+ toggleIgnoredHref,
287
+ showIgnored,
288
+ querySuffix,
289
+ relPathPosix,
290
+ bodyHtml: body,
291
+ repoBase,
292
+ repos,
293
+ currentRepoId,
294
+ });
295
+ }
296
+ export function renderFilePage({ title, repoName, gitInfo, brokenLinks, querySuffix, toggleIgnoredHref, showIgnored, relPathPosix, fileName, isMarkdown, mediaType, renderedHtml, repoBase, repos, currentRepoId, }) {
297
+ const relDir = path.posix.dirname(relPathPosix || "");
298
+ const suffix = querySuffix || "";
299
+ const rawHref = `${repoBase}/raw/${encodePathForUrl(relPathPosix || "")}${suffix}`;
300
+ const treeHref = `${repoBase}/tree/${encodePathForUrl(relDir === "." ? "" : relDir)}${suffix}`;
301
+ const wrapClass = mediaType ? `${mediaType}-wrap` : isMarkdown ? "markdown-body markdown-wrap" : "code-wrap";
302
+ const body = `<section class="panel">
303
+ <div class="panel-title">
304
+ <span class="filename">${escapeHtml(fileName)}</span>
305
+ <span class="spacer"></span>
306
+ <a class="btn" href="${treeHref}">Back</a>
307
+ <a class="btn" href="${rawHref}">Raw</a>
308
+ </div>
309
+ <div class="${wrapClass}">
310
+ ${renderedHtml}
311
+ </div>
312
+ </section>`;
313
+ return pageTemplateWithLinks({
314
+ title,
315
+ repoName,
316
+ gitInfo,
317
+ brokenLinks,
318
+ toggleIgnoredHref,
319
+ showIgnored,
320
+ querySuffix,
321
+ relPathPosix,
322
+ bodyHtml: body,
323
+ repoBase,
324
+ repos,
325
+ currentRepoId,
326
+ });
327
+ }
328
+ export function renderDiffPage({ title, repoName, gitInfo, relPathPosix, querySuffix, base, branches, tags, diffHtml, tooLarge, empty, fileCount, showAll, repoBase, repos, currentRepoId, }) {
329
+ const branchOptions = branches
330
+ .map((b) => {
331
+ const sel = b === base ? " selected" : "";
332
+ return `<option value="${escapeHtml(b)}"${sel}>${escapeHtml(b)}</option>`;
333
+ })
334
+ .join("\n");
335
+ const tagOptions = tags
336
+ .map((t) => {
337
+ const sel = t === base ? " selected" : "";
338
+ return `<option value="${escapeHtml(t)}"${sel}>${escapeHtml(t)}</option>`;
339
+ })
340
+ .join("\n");
341
+ const headSelected = base === "HEAD" ? " selected" : "";
342
+ const selector = `<select id="base-selector" class="base-selector">
343
+ <option value="HEAD"${headSelected}>HEAD</option>
344
+ ${branches.length ? `<optgroup label="Branches">${branchOptions}</optgroup>` : ""}
345
+ ${tags.length ? `<optgroup label="Tags">${tagOptions}</optgroup>` : ""}
346
+ </select>`;
347
+ let content = "";
348
+ if (tooLarge) {
349
+ content = `<div class="diff-empty note">Diff output exceeded 512KB and was truncated. Try narrowing the comparison range.</div>`;
350
+ }
351
+ else if (empty) {
352
+ content = `<div class="diff-empty note">No changes found.</div>`;
353
+ }
354
+ else {
355
+ content = diffHtml;
356
+ }
357
+ const MAX_DIFF_FILES = 30;
358
+ let truncatedMsg = "";
359
+ if (fileCount > MAX_DIFF_FILES && !showAll) {
360
+ const hidden = fileCount - MAX_DIFF_FILES;
361
+ const showAllQuery = new URLSearchParams(querySuffix ? querySuffix.slice(1) : "");
362
+ showAllQuery.set("show_all", "1");
363
+ const showAllHref = `${repoBase}/diff?${showAllQuery.toString()}`;
364
+ truncatedMsg = `<div class="diff-truncated note">${hidden} more file${hidden === 1 ? "" : "s"} not shown. <a class="link" href="${showAllHref}">Show all ${fileCount} files</a></div>`;
365
+ }
366
+ const body = `<section class="panel">
367
+ <div class="panel-title">
368
+ <span>Compare working tree against</span>
369
+ ${selector}
370
+ <span class="spacer"></span>
371
+ <a class="btn" href="${repoBase}/tree/${querySuffix || ""}">Back</a>
372
+ </div>
373
+ <div class="diff-wrap">
374
+ ${content}
375
+ ${truncatedMsg}
376
+ </div>
377
+ </section>`;
378
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
379
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
380
+ const repoSwitcher = renderRepoSwitcher(repos, currentRepoId, repoName);
381
+ return `<!doctype html>
382
+ <html lang="en">
383
+ <head>
384
+ <meta charset="utf-8" />
385
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
386
+ <title>${escapeHtml(title)}</title>
387
+ <link rel="stylesheet" href="/static/vendor/diff2html/diff2html.min.css" />
388
+ <link rel="stylesheet" href="/static/app.css" />
389
+ </head>
390
+ <body data-repo-base="${escapeHtml(repoBase)}">
391
+ <header class="topbar">
392
+ <div class="topbar-row">
393
+ <a class="brand" href="${repoBase}/tree/${querySuffix || ""}">${escapeHtml(repoName)}</a>
394
+ ${repoSwitcher}
395
+ <div class="meta">
396
+ <span class="pill">${branch}</span>
397
+ ${commit ? `<span class="pill mono">${commit}</span>` : ""}
398
+ </div>
399
+ </div>
400
+ </header>
401
+ <main class="container">
402
+ ${body}
403
+ </main>
404
+ <script type="module" src="/static/app.js"></script>
405
+ </body>
406
+ </html>`;
407
+ }
408
+ export function renderReviewListPage({ title, repoName, gitInfo, threads, repoBase, repos, currentRepoId, }) {
409
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
410
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
411
+ const threadRows = threads.length
412
+ ? threads
413
+ .map((t) => {
414
+ const unread = t.readUntil
415
+ ? t.lastMessageId && t.lastMessageId > t.readUntil
416
+ : t.messageCount > 0;
417
+ const badge = unread ? `<span class="review-unread-badge">${t.unreadCount || "new"}</span>` : "";
418
+ const timeAgo = escapeHtml(formatReviewTime(t.lastActivityAt || t.createdAt));
419
+ return `<a class="review-thread-row" href="${repoBase}/review/${encodeURIComponent(t.id)}">
420
+ <div class="review-thread-info">
421
+ <span class="review-thread-title">${escapeHtml(t.title)}${badge}</span>
422
+ <span class="review-thread-meta">${t.messageCount} message${t.messageCount !== 1 ? "s" : ""} · ${timeAgo}</span>
423
+ </div>
424
+ <span class="review-thread-arrow">›</span>
425
+ </a>`;
426
+ })
427
+ .join("\n")
428
+ : `<div class="review-empty">No review threads yet.</div>`;
429
+ const body = `<section class="panel">
430
+ <div class="panel-title">Review Threads</div>
431
+ <div class="review-thread-list">
432
+ ${threadRows}
433
+ </div>
434
+ </section>`;
435
+ return `<!doctype html>
436
+ <html lang="en">
437
+ <head>
438
+ <meta charset="utf-8" />
439
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
440
+ <title>${escapeHtml(title)}</title>
441
+ <link rel="stylesheet" href="/static/app.css" />
442
+ </head>
443
+ <body data-repo-base="${escapeHtml(repoBase)}">
444
+ <header class="topbar">
445
+ <div class="topbar-row">
446
+ <a class="brand" href="${repoBase}/tree/">${escapeHtml(repoName)}</a>
447
+ ${renderRepoSwitcher(repos, currentRepoId, repoName)}
448
+ <div class="meta">
449
+ <span class="pill">${branch}</span>
450
+ ${commit ? `<span class="pill mono">${commit}</span>` : ""}
451
+ </div>
452
+ </div>
453
+ </header>
454
+ <main class="container">
455
+ ${body}
456
+ </main>
457
+ <script type="module" src="/static/app.js"></script>
458
+ </body>
459
+ </html>`;
460
+ }
461
+ function renderCommentCard(c, msgId) {
462
+ return `<div class="review-comment-card${c.resolved ? " resolved" : ""}" data-comment-id="${escapeHtml(c.id)}" data-message-id="${escapeHtml(msgId)}" data-anchor-line="${c.anchorLine || ""}" data-anchor-end-line="${c.anchorEndLine || ""}">
463
+ <div class="review-comment-header">
464
+ <span class="review-comment-anchor">${c.anchorLine ? `Line ${c.anchorLine}${c.anchorEndLine && c.anchorEndLine !== c.anchorLine ? `-${c.anchorEndLine}` : ""}` : "General"}</span>
465
+ <span class="review-comment-time" title="${escapeHtml(c.createdAt)}">${escapeHtml(formatReviewTime(c.createdAt))}</span>
466
+ <span class="review-comment-actions">
467
+ ${!c.resolved ? `<button class="btn btn-sm review-resolve-btn" data-comment-id="${escapeHtml(c.id)}">Resolve</button>` : `<span class="review-resolved-label">Resolved</span>`}
468
+ <button class="btn btn-sm review-delete-comment-btn" data-comment-id="${escapeHtml(c.id)}">Delete</button>
469
+ </span>
470
+ </div>
471
+ <div class="review-comment-body">${escapeHtml(c.body)}</div>
472
+ </div>`;
473
+ }
474
+ export function renderReviewThreadPage({ title, repoName, gitInfo, thread, messages, comments, renderedMessages, repoBase, repos, currentRepoId, }) {
475
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
476
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
477
+ const messageBlocks = messages
478
+ .map((msg, idx) => {
479
+ const isAgent = msg.role === "agent";
480
+ const roleClass = isAgent ? "review-msg-agent" : "review-msg-user";
481
+ const roleLabel = isAgent ? "Agent" : "You";
482
+ const rendered = renderedMessages[idx];
483
+ // Gather comments for this message
484
+ const msgComments = comments.filter((c) => c.messageId === msg.id);
485
+ const commentHtml = msgComments.length
486
+ ? `<div class="review-inline-comments">${msgComments.map((c) => renderCommentCard(c, msg.id)).join("\n")}</div>`
487
+ : "";
488
+ const contentWrapper = isAgent
489
+ ? `<div class="markdown-body markdown-wrap review-msg-content" data-message-id="${escapeHtml(msg.id)}">${rendered}</div>`
490
+ : `<div class="review-msg-content review-msg-text" data-message-id="${escapeHtml(msg.id)}">${escapeHtml(rendered)}</div>`;
491
+ return `<div class="review-message ${roleClass}" data-message-id="${escapeHtml(msg.id)}">
492
+ <div class="review-msg-header">
493
+ <span class="review-msg-role">${roleLabel}</span>
494
+ <span class="review-msg-time" title="${escapeHtml(msg.createdAt)}">${escapeHtml(formatReviewTime(msg.createdAt))}</span>
495
+ </div>
496
+ ${contentWrapper}
497
+ ${commentHtml}
498
+ </div>`;
499
+ })
500
+ .join("\n");
501
+ const body = `<section class="panel review-thread-panel">
502
+ <div class="panel-title review-thread-header">
503
+ <a class="btn" href="${repoBase}/review/">← Back</a>
504
+ <span class="review-thread-title-text">${escapeHtml(thread.title)}</span>
505
+ <span class="spacer"></span>
506
+ </div>
507
+ <div class="review-messages">
508
+ ${messageBlocks || `<div class="review-empty">No messages yet.</div>`}
509
+ </div>
510
+ <div class="review-reply-form">
511
+ <textarea id="review-reply-text" class="review-reply-textarea" placeholder="Write a reply..." rows="4"></textarea>
512
+ <button id="review-reply-submit" class="btn review-reply-btn" data-thread-id="${escapeHtml(thread.id)}">Submit Reply</button>
513
+ </div>
514
+ </section>`;
515
+ return `<!doctype html>
516
+ <html lang="en">
517
+ <head>
518
+ <meta charset="utf-8" />
519
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
520
+ <title>${escapeHtml(title)}</title>
521
+ <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
522
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
523
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
524
+ <link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
525
+ <link rel="stylesheet" href="/static/app.css" />
526
+ </head>
527
+ <body data-repo-base="${escapeHtml(repoBase)}">
528
+ <header class="topbar">
529
+ <div class="topbar-row">
530
+ <a class="brand" href="${repoBase}/tree/">${escapeHtml(repoName)}</a>
531
+ ${renderRepoSwitcher(repos, currentRepoId, repoName)}
532
+ <div class="meta">
533
+ <span class="pill">${branch}</span>
534
+ ${commit ? `<span class="pill mono">${commit}</span>` : ""}
535
+ <span id="conn-status" class="conn-status" title="Live reload: connecting..."></span>
536
+ </div>
537
+ </div>
538
+ </header>
539
+ <main class="container">
540
+ ${body}
541
+ </main>
542
+ <script defer src="/static/vendor/katex/katex.min.js"></script>
543
+ <script defer src="/static/vendor/katex/contrib/auto-render.min.js"></script>
544
+ <script type="module" src="/static/app.js"></script>
545
+ <script type="module" src="/static/review.js"></script>
546
+ </body>
547
+ </html>`;
548
+ }
549
+ export function renderErrorPage({ title, message, repoBase }) {
550
+ return `<!doctype html>
551
+ <html lang="en">
552
+ <head>
553
+ <meta charset="utf-8" />
554
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
555
+ <title>${escapeHtml(title)}</title>
556
+ <link rel="stylesheet" href="/static/app.css" />
557
+ </head>
558
+ <body data-repo-base="${escapeHtml(repoBase)}">
559
+ <main class="container">
560
+ <section class="panel">
561
+ <div class="panel-title">Error</div>
562
+ <div class="error">${escapeHtml(message)}</div>
563
+ </section>
564
+ </main>
565
+ </body>
566
+ </html>`;
567
+ }
568
+ export function renderBrokenLinksPage({ title, repoName, gitInfo, relPathPosix, scanState, querySuffix, toggleIgnoredHref, showIgnored, repoBase, repos, currentRepoId, }) {
569
+ const state = scanState || {};
570
+ const result = state.lastResult;
571
+ const broken = result?.broken || [];
572
+ const statusLine = state.status === "running"
573
+ ? "Scanning…"
574
+ : result
575
+ ? `Last scan: ${new Date(result.finishedAt).toLocaleString()} · Files: ${result.filesScanned} · URLs: ${result.urlsChecked} · Broken: ${broken.length} · ${result.durationMs}ms`
576
+ : state.lastError
577
+ ? `Last error: ${escapeHtml(state.lastError)}`
578
+ : "No scan yet.";
579
+ const grouped = new Map();
580
+ for (const b of broken) {
581
+ const arr = grouped.get(b.source) || [];
582
+ arr.push(b);
583
+ grouped.set(b.source, arr);
584
+ }
585
+ const sections = Array.from(grouped.entries())
586
+ .sort((a, b) => a[0].localeCompare(b[0]))
587
+ .map(([source, items]) => {
588
+ const sourceHref = `${repoBase}/blob/${source.split("/").map(encodeURIComponent).join("/")}${querySuffix || ""}`;
589
+ const rows = items
590
+ .map((i) => {
591
+ const reason = escapeHtml(i.reason || "");
592
+ const kind = escapeHtml(i.kind || "");
593
+ const url = escapeHtml(i.url || "");
594
+ const target = i.target ? escapeHtml(i.target) : "";
595
+ return `<tr><td class="mono">${kind}</td><td class="mono">${reason}</td><td class="mono">${url}</td><td class="mono">${target}</td></tr>`;
596
+ })
597
+ .join("\n");
598
+ return `<section class="panel">
599
+ <div class="panel-title">
600
+ <span class="filename"><a class="link" href="${sourceHref}">${escapeHtml(source)}</a></span>
601
+ <span class="spacer"></span>
602
+ <span class="pill">${items.length}</span>
603
+ </div>
604
+ <div class="table-wrap">
605
+ <table class="file-table linkcheck">
606
+ <thead><tr><th>Kind</th><th>Reason</th><th>URL</th><th>Target</th></tr></thead>
607
+ <tbody>${rows}</tbody>
608
+ </table>
609
+ </div>
610
+ </section>`;
611
+ })
612
+ .join("\n");
613
+ const body = `<section class="panel">
614
+ <div class="panel-title">Broken links</div>
615
+ <div class="note">${escapeHtml(statusLine)}</div>
616
+ </section>
617
+ ${sections || `<section class="panel"><div class="panel-title">All good</div><div class="note">No broken internal links found.</div></section>`}`;
618
+ return pageTemplateWithLinks({
619
+ title,
620
+ repoName,
621
+ gitInfo,
622
+ brokenLinks: scanState,
623
+ toggleIgnoredHref,
624
+ showIgnored,
625
+ querySuffix,
626
+ relPathPosix,
627
+ bodyHtml: body,
628
+ repoBase,
629
+ repos,
630
+ currentRepoId,
631
+ });
632
+ }
633
+ //# sourceMappingURL=views.js.map