repoview 0.5.1 → 0.6.1

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