repoview 0.5.0 → 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.
- package/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +4 -3
- package/DEVELOPMENT.md +70 -16
- package/README.md +36 -5
- package/dist/api.js +58 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.js +224 -0
- package/dist/cli.js.map +1 -0
- package/dist/csv.js +64 -0
- package/dist/csv.js.map +1 -0
- package/dist/format.js +25 -0
- package/dist/format.js.map +1 -0
- package/dist/git.js +67 -0
- package/dist/git.js.map +1 -0
- package/dist/gitignore.js +34 -0
- package/dist/gitignore.js.map +1 -0
- package/dist/linkcheck.js +310 -0
- package/dist/linkcheck.js.map +1 -0
- package/dist/markdown.js +493 -0
- package/dist/markdown.js.map +1 -0
- package/dist/net.js +10 -0
- package/dist/net.js.map +1 -0
- package/dist/paths.js +59 -0
- package/dist/paths.js.map +1 -0
- package/dist/reload.js +36 -0
- package/dist/reload.js.map +1 -0
- package/dist/repo-context.js +73 -0
- package/dist/repo-context.js.map +1 -0
- package/dist/repo-router.js +801 -0
- package/dist/repo-router.js.map +1 -0
- package/dist/review-cli.js +228 -0
- package/dist/review-cli.js.map +1 -0
- package/dist/server.js +116 -0
- package/dist/server.js.map +1 -0
- package/dist/session.js +86 -0
- package/dist/session.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/views.js +633 -0
- package/dist/views.js.map +1 -0
- package/package.json +22 -6
- package/public/app.css +842 -0
- package/public/app.js +35 -2
- package/public/review.js +587 -0
- package/public/session.js +61 -0
- package/src/cli.js +0 -73
- package/src/gitignore.js +0 -34
- package/src/linkcheck.js +0 -312
- package/src/markdown.js +0 -364
- package/src/server.js +0 -760
- package/src/views.js +0 -479
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import mime from "mime-types";
|
|
5
|
+
import diff2html from "diff2html";
|
|
6
|
+
import { escapeHtml, renderBrokenLinksPage, renderDiffPage, renderErrorPage, renderFilePage, renderReviewListPage, renderReviewThreadPage, renderSessionPage, renderTreePage, } from "./views.js";
|
|
7
|
+
import { toPosixPath, encodePathForUrl, safeRealpath, statSafe } from "./paths.js";
|
|
8
|
+
import { isLoopbackAddress } from "./net.js";
|
|
9
|
+
import { formatBytes, formatDate } from "./format.js";
|
|
10
|
+
import { parseCsv, renderCsvTable } from "./csv.js";
|
|
11
|
+
import { validateGitRef, getGitBranches, getGitTags, getGitDiffRaw, execGit, } from "./git.js";
|
|
12
|
+
const THREAD_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
13
|
+
function qstr(v) {
|
|
14
|
+
return typeof v === "string" ? v : undefined;
|
|
15
|
+
}
|
|
16
|
+
function validateThreadId(id) {
|
|
17
|
+
return !!id && typeof id === "string" && THREAD_ID_RE.test(id);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build an express.Router serving a single repo (the given context). All
|
|
21
|
+
* generated app URLs are prefixed with `repoBase` (e.g. "/r/myrepo").
|
|
22
|
+
*/
|
|
23
|
+
function buildRepoRouter(ctx, repoBase, session) {
|
|
24
|
+
const { md } = ctx;
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
const errorPage = (title, message) => renderErrorPage({ title, message, repoBase, repos: session.listRepos(), currentRepoId: ctx.id });
|
|
27
|
+
router.get("/rev", (req, res) => {
|
|
28
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
29
|
+
res.status(200).send({ revision: ctx.reloadHub.getRevision() });
|
|
30
|
+
});
|
|
31
|
+
router.get("/broken-links.json", (req, res) => {
|
|
32
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
33
|
+
res.status(200).send(ctx.linkScanner.getState());
|
|
34
|
+
});
|
|
35
|
+
router.get("/broken-links", (req, res) => {
|
|
36
|
+
const showIgnored = req.query.ignored === "1";
|
|
37
|
+
const query = new URLSearchParams();
|
|
38
|
+
if (req.query.watch === "0")
|
|
39
|
+
query.set("watch", "0");
|
|
40
|
+
if (showIgnored)
|
|
41
|
+
query.set("ignored", "1");
|
|
42
|
+
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
43
|
+
const toggleIgnoredSuffix = (() => {
|
|
44
|
+
const q = new URLSearchParams(query);
|
|
45
|
+
if (showIgnored)
|
|
46
|
+
q.delete("ignored");
|
|
47
|
+
else
|
|
48
|
+
q.set("ignored", "1");
|
|
49
|
+
return q.toString() ? `?${q.toString()}` : "";
|
|
50
|
+
})();
|
|
51
|
+
const toggleIgnoredHref = `${repoBase}/broken-links${toggleIgnoredSuffix}`;
|
|
52
|
+
const state = ctx.linkScanner.getState();
|
|
53
|
+
res.status(200).send(renderBrokenLinksPage({
|
|
54
|
+
title: `${ctx.repoName} · Broken links`,
|
|
55
|
+
repoName: ctx.repoName,
|
|
56
|
+
gitInfo: ctx.gitInfo,
|
|
57
|
+
relPathPosix: "",
|
|
58
|
+
scanState: state,
|
|
59
|
+
querySuffix,
|
|
60
|
+
toggleIgnoredHref,
|
|
61
|
+
showIgnored,
|
|
62
|
+
repoBase,
|
|
63
|
+
repos: session.listRepos(),
|
|
64
|
+
currentRepoId: ctx.id,
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
67
|
+
router.get("/events", (req, res) => {
|
|
68
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
69
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
70
|
+
res.setHeader("Connection", "keep-alive");
|
|
71
|
+
res.flushHeaders?.();
|
|
72
|
+
res.write("event: hello\ndata: ok\n\n");
|
|
73
|
+
ctx.reloadHub.add(res);
|
|
74
|
+
const interval = setInterval(() => {
|
|
75
|
+
try {
|
|
76
|
+
res.write(":\n\n");
|
|
77
|
+
ctx.reloadHub.broadcastPing();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
}, 15000);
|
|
83
|
+
res.on("close", () => clearInterval(interval));
|
|
84
|
+
});
|
|
85
|
+
router.get("/diff", async (req, res) => {
|
|
86
|
+
try {
|
|
87
|
+
const base = qstr(req.query.base) || "HEAD";
|
|
88
|
+
if (!validateGitRef(base)) {
|
|
89
|
+
const err = new Error("Invalid base ref");
|
|
90
|
+
err.statusCode = 400;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
if (!ctx.gitInfo.commit) {
|
|
94
|
+
const err = new Error("Not a git repository");
|
|
95
|
+
err.statusCode = 400;
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
const query = new URLSearchParams();
|
|
99
|
+
if (req.query.watch === "0")
|
|
100
|
+
query.set("watch", "0");
|
|
101
|
+
if (req.query.ignored === "1")
|
|
102
|
+
query.set("ignored", "1");
|
|
103
|
+
if (base !== "HEAD")
|
|
104
|
+
query.set("base", base);
|
|
105
|
+
if (req.query.show_all === "1")
|
|
106
|
+
query.set("show_all", "1");
|
|
107
|
+
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
108
|
+
const [branches, tags, diffResult] = await Promise.all([
|
|
109
|
+
getGitBranches(ctx.repoRootReal),
|
|
110
|
+
getGitTags(ctx.repoRootReal),
|
|
111
|
+
getGitDiffRaw(ctx.repoRootReal, base),
|
|
112
|
+
]);
|
|
113
|
+
const MAX_DIFF_FILES = 30;
|
|
114
|
+
let diffHtml = "";
|
|
115
|
+
let fileCount = 0;
|
|
116
|
+
const showAll = req.query.show_all === "1";
|
|
117
|
+
if (!diffResult.tooLarge && diffResult.raw) {
|
|
118
|
+
const parsed = diff2html.parse(diffResult.raw);
|
|
119
|
+
fileCount = parsed.length;
|
|
120
|
+
const toRender = (!showAll && parsed.length > MAX_DIFF_FILES)
|
|
121
|
+
? parsed.slice(0, MAX_DIFF_FILES)
|
|
122
|
+
: parsed;
|
|
123
|
+
diffHtml = diff2html.html(toRender, {
|
|
124
|
+
outputFormat: "line-by-line",
|
|
125
|
+
drawFileList: true,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
res.status(200).send(renderDiffPage({
|
|
129
|
+
title: `${ctx.repoName} · Diff`,
|
|
130
|
+
repoName: ctx.repoName,
|
|
131
|
+
gitInfo: ctx.gitInfo,
|
|
132
|
+
relPathPosix: "",
|
|
133
|
+
querySuffix,
|
|
134
|
+
base,
|
|
135
|
+
branches,
|
|
136
|
+
tags,
|
|
137
|
+
diffHtml,
|
|
138
|
+
tooLarge: diffResult.tooLarge,
|
|
139
|
+
empty: !diffResult.raw,
|
|
140
|
+
fileCount,
|
|
141
|
+
showAll,
|
|
142
|
+
repoBase,
|
|
143
|
+
repos: session.listRepos(),
|
|
144
|
+
currentRepoId: ctx.id,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
const err = e;
|
|
149
|
+
res
|
|
150
|
+
.status(err.statusCode || 500)
|
|
151
|
+
.send(errorPage("Error", e.message ?? "Error"));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
router.get(["/tree/*", "/tree"], async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const showIgnored = req.query.ignored === "1";
|
|
157
|
+
const query = new URLSearchParams();
|
|
158
|
+
if (req.query.watch === "0")
|
|
159
|
+
query.set("watch", "0");
|
|
160
|
+
if (showIgnored)
|
|
161
|
+
query.set("ignored", "1");
|
|
162
|
+
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
163
|
+
const toggleIgnoredSuffix = (() => {
|
|
164
|
+
const q = new URLSearchParams(query);
|
|
165
|
+
if (showIgnored)
|
|
166
|
+
q.delete("ignored");
|
|
167
|
+
else
|
|
168
|
+
q.set("ignored", "1");
|
|
169
|
+
return q.toString() ? `?${q.toString()}` : "";
|
|
170
|
+
})();
|
|
171
|
+
const p = req.params[0] ?? "";
|
|
172
|
+
const { stripped, resolved } = await safeRealpath(ctx.repoRootReal, p);
|
|
173
|
+
const toggleIgnoredHref = `${repoBase}/tree/${encodePathForUrl(toPosixPath(stripped))}${toggleIgnoredSuffix}`;
|
|
174
|
+
const st = await statSafe(resolved);
|
|
175
|
+
if (st === null) {
|
|
176
|
+
const err = new Error("Permission denied");
|
|
177
|
+
err.statusCode = 403;
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
if (st.isFile)
|
|
181
|
+
return res.redirect(`${repoBase}/blob/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`);
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
if (e.code === "EACCES" || e.code === "EPERM") {
|
|
188
|
+
const err = new Error("Permission denied");
|
|
189
|
+
err.statusCode = 403;
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
const readmeEntry = entries.find((e) => e.isFile() &&
|
|
195
|
+
/^readme(?:\.(?:md|markdown|mdown|mkd|mkdn))?$/i.test(e.name));
|
|
196
|
+
const rows = (await Promise.all(entries
|
|
197
|
+
.filter((e) => {
|
|
198
|
+
if (e.name === ".git")
|
|
199
|
+
return false;
|
|
200
|
+
if (showIgnored)
|
|
201
|
+
return true;
|
|
202
|
+
const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
|
|
203
|
+
return !ctx.ignoreMatcher.ignores(relPosix, { isDir: e.isDirectory() });
|
|
204
|
+
})
|
|
205
|
+
.map(async (e) => {
|
|
206
|
+
const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
|
|
207
|
+
const full = path.join(resolved, e.name);
|
|
208
|
+
const info = await statSafe(full, { followSymlinks: false });
|
|
209
|
+
if (info === null)
|
|
210
|
+
return null;
|
|
211
|
+
const isDir = e.isDirectory();
|
|
212
|
+
const href = isDir
|
|
213
|
+
? `${repoBase}/tree/${encodePathForUrl(relPosix)}${querySuffix}`
|
|
214
|
+
: `${repoBase}/blob/${encodePathForUrl(relPosix)}${querySuffix}`;
|
|
215
|
+
return {
|
|
216
|
+
name: e.name,
|
|
217
|
+
isDir,
|
|
218
|
+
href,
|
|
219
|
+
size: isDir ? "" : formatBytes(info.size),
|
|
220
|
+
mtime: formatDate(info.mtimeMs),
|
|
221
|
+
mtimeMs: info.mtimeMs,
|
|
222
|
+
};
|
|
223
|
+
}))).filter((row) => row !== null);
|
|
224
|
+
rows.sort((a, b) => {
|
|
225
|
+
if (a.isDir !== b.isDir)
|
|
226
|
+
return a.isDir ? -1 : 1;
|
|
227
|
+
return a.name.localeCompare(b.name);
|
|
228
|
+
});
|
|
229
|
+
let readmeHtml = "";
|
|
230
|
+
if (readmeEntry) {
|
|
231
|
+
try {
|
|
232
|
+
const readmeRel = toPosixPath(path.posix.join(toPosixPath(stripped), readmeEntry.name));
|
|
233
|
+
if (!showIgnored && ctx.ignoreMatcher.ignores(readmeRel, { isDir: false }))
|
|
234
|
+
throw new Error("ignored");
|
|
235
|
+
const { resolved: readmePath } = await safeRealpath(ctx.repoRootReal, readmeRel);
|
|
236
|
+
const readmeStat = await statSafe(readmePath);
|
|
237
|
+
if (readmeStat && readmeStat.size <= 2 * 1024 * 1024) {
|
|
238
|
+
const buf = await fs.readFile(readmePath);
|
|
239
|
+
readmeHtml = md.render(buf.toString("utf8"), {
|
|
240
|
+
baseDirPosix: toPosixPath(stripped),
|
|
241
|
+
repoBase,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
readmeHtml = "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
res.status(200).send(renderTreePage({
|
|
250
|
+
title: `${ctx.repoName}${stripped ? `/${stripped}` : ""}`,
|
|
251
|
+
repoName: ctx.repoName,
|
|
252
|
+
gitInfo: ctx.gitInfo,
|
|
253
|
+
brokenLinks: ctx.linkScanner.getState(),
|
|
254
|
+
relPathPosix: toPosixPath(stripped),
|
|
255
|
+
querySuffix,
|
|
256
|
+
toggleIgnoredHref,
|
|
257
|
+
showIgnored,
|
|
258
|
+
rows,
|
|
259
|
+
readmeHtml,
|
|
260
|
+
repoBase,
|
|
261
|
+
repos: session.listRepos(),
|
|
262
|
+
currentRepoId: ctx.id,
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
const err = e;
|
|
267
|
+
res
|
|
268
|
+
.status(err.statusCode || 500)
|
|
269
|
+
.send(errorPage("Error", e.message ?? "Error"));
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
router.get(["/blob/*", "/blob"], async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const showIgnored = req.query.ignored === "1";
|
|
275
|
+
const query = new URLSearchParams();
|
|
276
|
+
if (req.query.watch === "0")
|
|
277
|
+
query.set("watch", "0");
|
|
278
|
+
if (showIgnored)
|
|
279
|
+
query.set("ignored", "1");
|
|
280
|
+
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
281
|
+
const toggleIgnoredSuffix = (() => {
|
|
282
|
+
const q = new URLSearchParams(query);
|
|
283
|
+
if (showIgnored)
|
|
284
|
+
q.delete("ignored");
|
|
285
|
+
else
|
|
286
|
+
q.set("ignored", "1");
|
|
287
|
+
return q.toString() ? `?${q.toString()}` : "";
|
|
288
|
+
})();
|
|
289
|
+
const p = req.params[0] ?? "";
|
|
290
|
+
const { stripped, resolved } = await safeRealpath(ctx.repoRootReal, p);
|
|
291
|
+
const toggleIgnoredHref = `${repoBase}/blob/${encodePathForUrl(toPosixPath(stripped))}${toggleIgnoredSuffix}`;
|
|
292
|
+
const st = await statSafe(resolved);
|
|
293
|
+
if (st === null) {
|
|
294
|
+
const err = new Error("Permission denied");
|
|
295
|
+
err.statusCode = 403;
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
if (st.isDir)
|
|
299
|
+
return res.redirect(`${repoBase}/tree/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`);
|
|
300
|
+
const fileName = path.basename(resolved);
|
|
301
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
302
|
+
const isMarkdown = [".md", ".markdown", ".mdown", ".mkd", ".mkdn"].includes(ext);
|
|
303
|
+
const isPdf = ext === ".pdf";
|
|
304
|
+
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp"].includes(ext);
|
|
305
|
+
const isCsv = [".csv", ".tsv"].includes(ext);
|
|
306
|
+
const maxBytes = 2 * 1024 * 1024;
|
|
307
|
+
const rawSrc = `${repoBase}/raw/${encodePathForUrl(toPosixPath(stripped))}`;
|
|
308
|
+
if (isPdf) {
|
|
309
|
+
res.status(200).send(renderFilePage({
|
|
310
|
+
title: `${ctx.repoName}/${stripped}`,
|
|
311
|
+
repoName: ctx.repoName,
|
|
312
|
+
gitInfo: ctx.gitInfo,
|
|
313
|
+
brokenLinks: ctx.linkScanner.getState(),
|
|
314
|
+
relPathPosix: toPosixPath(stripped),
|
|
315
|
+
querySuffix,
|
|
316
|
+
toggleIgnoredHref,
|
|
317
|
+
showIgnored,
|
|
318
|
+
fileName,
|
|
319
|
+
isMarkdown: false,
|
|
320
|
+
mediaType: "pdf",
|
|
321
|
+
renderedHtml: `<iframe class="pdf-frame" src="${rawSrc}"></iframe>`,
|
|
322
|
+
repoBase,
|
|
323
|
+
repos: session.listRepos(),
|
|
324
|
+
currentRepoId: ctx.id,
|
|
325
|
+
}));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (isImage) {
|
|
329
|
+
res.status(200).send(renderFilePage({
|
|
330
|
+
title: `${ctx.repoName}/${stripped}`,
|
|
331
|
+
repoName: ctx.repoName,
|
|
332
|
+
gitInfo: ctx.gitInfo,
|
|
333
|
+
brokenLinks: ctx.linkScanner.getState(),
|
|
334
|
+
relPathPosix: toPosixPath(stripped),
|
|
335
|
+
querySuffix,
|
|
336
|
+
toggleIgnoredHref,
|
|
337
|
+
showIgnored,
|
|
338
|
+
fileName,
|
|
339
|
+
isMarkdown: false,
|
|
340
|
+
mediaType: "image",
|
|
341
|
+
renderedHtml: `<img class="image-preview" src="${rawSrc}" alt="${escapeHtml(fileName)}" />`,
|
|
342
|
+
repoBase,
|
|
343
|
+
repos: session.listRepos(),
|
|
344
|
+
currentRepoId: ctx.id,
|
|
345
|
+
}));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (st.size > maxBytes) {
|
|
349
|
+
res.status(200).send(renderFilePage({
|
|
350
|
+
title: `${ctx.repoName}/${stripped}`,
|
|
351
|
+
repoName: ctx.repoName,
|
|
352
|
+
gitInfo: ctx.gitInfo,
|
|
353
|
+
brokenLinks: ctx.linkScanner.getState(),
|
|
354
|
+
relPathPosix: toPosixPath(stripped),
|
|
355
|
+
querySuffix,
|
|
356
|
+
toggleIgnoredHref,
|
|
357
|
+
showIgnored,
|
|
358
|
+
fileName,
|
|
359
|
+
isMarkdown: false,
|
|
360
|
+
renderedHtml: `<div class="note">File is too large to render (${formatBytes(st.size)}). Use <a href="${repoBase}/raw/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}">Raw</a>.</div>`,
|
|
361
|
+
repoBase,
|
|
362
|
+
repos: session.listRepos(),
|
|
363
|
+
currentRepoId: ctx.id,
|
|
364
|
+
}));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const raw = await fs.readFile(resolved);
|
|
368
|
+
const text = raw.toString("utf8");
|
|
369
|
+
let renderedHtml;
|
|
370
|
+
let mediaType;
|
|
371
|
+
if (isCsv) {
|
|
372
|
+
const delimiter = ext === ".tsv" ? "\t" : ",";
|
|
373
|
+
const rows = parseCsv(text, delimiter);
|
|
374
|
+
renderedHtml = renderCsvTable(rows, escapeHtml);
|
|
375
|
+
mediaType = "csv";
|
|
376
|
+
}
|
|
377
|
+
else if (isMarkdown) {
|
|
378
|
+
const baseDir = toPosixPath(path.posix.dirname(toPosixPath(stripped)));
|
|
379
|
+
renderedHtml = md.render(text, { baseDirPosix: baseDir === "." ? "" : baseDir, repoBase });
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
renderedHtml = md.renderCodeBlock(text, {
|
|
383
|
+
languageHint: ext ? ext.slice(1) : "",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
res.status(200).send(renderFilePage({
|
|
387
|
+
title: `${ctx.repoName}/${stripped}`,
|
|
388
|
+
repoName: ctx.repoName,
|
|
389
|
+
gitInfo: ctx.gitInfo,
|
|
390
|
+
brokenLinks: ctx.linkScanner.getState(),
|
|
391
|
+
relPathPosix: toPosixPath(stripped),
|
|
392
|
+
querySuffix,
|
|
393
|
+
toggleIgnoredHref,
|
|
394
|
+
showIgnored,
|
|
395
|
+
fileName,
|
|
396
|
+
isMarkdown,
|
|
397
|
+
mediaType,
|
|
398
|
+
renderedHtml,
|
|
399
|
+
repoBase,
|
|
400
|
+
repos: session.listRepos(),
|
|
401
|
+
currentRepoId: ctx.id,
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
const err = e;
|
|
406
|
+
res
|
|
407
|
+
.status(err.statusCode || 500)
|
|
408
|
+
.send(errorPage("Error", e.message ?? "Error"));
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
// --- Review routes ---
|
|
412
|
+
const reviewDir = ctx.reviewDir;
|
|
413
|
+
router.get("/review/", async (req, res) => {
|
|
414
|
+
try {
|
|
415
|
+
let entries = [];
|
|
416
|
+
try {
|
|
417
|
+
entries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// no review dir yet
|
|
421
|
+
}
|
|
422
|
+
const threads = [];
|
|
423
|
+
for (const entry of entries) {
|
|
424
|
+
if (!entry.isDirectory())
|
|
425
|
+
continue;
|
|
426
|
+
const threadFile = path.join(reviewDir, entry.name, "thread.json");
|
|
427
|
+
try {
|
|
428
|
+
const thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
429
|
+
let messageCount = 0;
|
|
430
|
+
let lastMessageId = null;
|
|
431
|
+
try {
|
|
432
|
+
const msgs = (await fs.readdir(path.join(reviewDir, entry.name, "messages")))
|
|
433
|
+
.filter((f) => f.endsWith(".json"))
|
|
434
|
+
.sort();
|
|
435
|
+
messageCount = msgs.length;
|
|
436
|
+
lastMessageId = msgs.length ? msgs[msgs.length - 1].replace(".json", "") : null;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// no messages
|
|
440
|
+
}
|
|
441
|
+
const unreadCount = thread.readUntil && lastMessageId
|
|
442
|
+
? Math.max(0, parseInt(lastMessageId, 10) - parseInt(thread.readUntil, 10))
|
|
443
|
+
: thread.readUntil ? 0 : messageCount;
|
|
444
|
+
threads.push({ ...thread, messageCount, lastMessageId, unreadCount });
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// skip
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
threads.sort((a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime());
|
|
451
|
+
res.status(200).send(renderReviewListPage({
|
|
452
|
+
title: `${ctx.repoName} · Reviews`,
|
|
453
|
+
repoName: ctx.repoName,
|
|
454
|
+
gitInfo: ctx.gitInfo,
|
|
455
|
+
threads,
|
|
456
|
+
repoBase,
|
|
457
|
+
repos: session.listRepos(),
|
|
458
|
+
currentRepoId: ctx.id,
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
res.status(500).send(errorPage("Error", e.message ?? "Error"));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
router.get("/review/:threadId", async (req, res) => {
|
|
466
|
+
try {
|
|
467
|
+
const { threadId } = req.params;
|
|
468
|
+
if (!validateThreadId(threadId)) {
|
|
469
|
+
return res.status(400).send(errorPage("Error", "Invalid thread ID"));
|
|
470
|
+
}
|
|
471
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
472
|
+
const threadFile = path.join(threadDir, "thread.json");
|
|
473
|
+
let thread;
|
|
474
|
+
try {
|
|
475
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return res.status(404).send(errorPage("Error", "Thread not found"));
|
|
479
|
+
}
|
|
480
|
+
const messagesDir = path.join(threadDir, "messages");
|
|
481
|
+
let messageFiles = [];
|
|
482
|
+
try {
|
|
483
|
+
messageFiles = (await fs.readdir(messagesDir)).filter((f) => f.endsWith(".json")).sort();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// no messages
|
|
487
|
+
}
|
|
488
|
+
const messages = [];
|
|
489
|
+
for (const f of messageFiles) {
|
|
490
|
+
messages.push(JSON.parse(await fs.readFile(path.join(messagesDir, f), "utf8")));
|
|
491
|
+
}
|
|
492
|
+
let comments = [];
|
|
493
|
+
try {
|
|
494
|
+
const commentsData = JSON.parse(await fs.readFile(path.join(threadDir, "comments.json"), "utf8"));
|
|
495
|
+
comments = commentsData.comments || [];
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// no comments
|
|
499
|
+
}
|
|
500
|
+
// Render agent messages as markdown, user messages as plain text
|
|
501
|
+
const renderedMessages = messages.map((msg) => {
|
|
502
|
+
if (msg.role === "agent" && msg.format === "markdown") {
|
|
503
|
+
return md.render(msg.body, { baseDirPosix: "", emitLineMap: true, repoBase });
|
|
504
|
+
}
|
|
505
|
+
return msg.body;
|
|
506
|
+
});
|
|
507
|
+
// Mark as read
|
|
508
|
+
if (messageFiles.length) {
|
|
509
|
+
const lastMsgId = messageFiles[messageFiles.length - 1].replace(".json", "");
|
|
510
|
+
thread.readUntil = lastMsgId;
|
|
511
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
512
|
+
}
|
|
513
|
+
res.status(200).send(renderReviewThreadPage({
|
|
514
|
+
title: `${ctx.repoName} · ${thread.title}`,
|
|
515
|
+
repoName: ctx.repoName,
|
|
516
|
+
gitInfo: ctx.gitInfo,
|
|
517
|
+
thread,
|
|
518
|
+
messages,
|
|
519
|
+
comments,
|
|
520
|
+
renderedMessages,
|
|
521
|
+
repoBase,
|
|
522
|
+
repos: session.listRepos(),
|
|
523
|
+
currentRepoId: ctx.id,
|
|
524
|
+
}));
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
res.status(500).send(errorPage("Error", e.message ?? "Error"));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
router.post("/review/:threadId/messages", express.json(), async (req, res) => {
|
|
531
|
+
try {
|
|
532
|
+
const { threadId } = req.params;
|
|
533
|
+
if (!validateThreadId(threadId)) {
|
|
534
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
535
|
+
}
|
|
536
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
537
|
+
const threadFile = path.join(threadDir, "thread.json");
|
|
538
|
+
const messagesDir = path.join(threadDir, "messages");
|
|
539
|
+
let thread;
|
|
540
|
+
try {
|
|
541
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return res.status(404).json({ error: "Thread not found" });
|
|
545
|
+
}
|
|
546
|
+
const { body } = req.body;
|
|
547
|
+
if (!body || !body.trim()) {
|
|
548
|
+
return res.status(400).json({ error: "Message body is required" });
|
|
549
|
+
}
|
|
550
|
+
let entries = [];
|
|
551
|
+
try {
|
|
552
|
+
entries = (await fs.readdir(messagesDir)).filter((f) => f.endsWith(".json"));
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
await fs.mkdir(messagesDir, { recursive: true });
|
|
556
|
+
}
|
|
557
|
+
const existingIds = entries.map((e) => e.replace(".json", ""));
|
|
558
|
+
let max = 0;
|
|
559
|
+
for (const id of existingIds) {
|
|
560
|
+
const n = parseInt(id, 10);
|
|
561
|
+
if (n > max)
|
|
562
|
+
max = n;
|
|
563
|
+
}
|
|
564
|
+
const nextId = String(max + 1).padStart(3, "0");
|
|
565
|
+
const now = new Date().toISOString();
|
|
566
|
+
const message = {
|
|
567
|
+
id: nextId,
|
|
568
|
+
role: "user",
|
|
569
|
+
format: "text",
|
|
570
|
+
body: body.trim(),
|
|
571
|
+
createdAt: now,
|
|
572
|
+
};
|
|
573
|
+
await fs.writeFile(path.join(messagesDir, `${nextId}.json`), JSON.stringify(message, null, 2) + "\n");
|
|
574
|
+
thread.lastActivityAt = now;
|
|
575
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
576
|
+
res.status(201).json(message);
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
res.status(500).json({ error: e.message });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
router.post("/review/:threadId/comments", express.json(), async (req, res) => {
|
|
583
|
+
try {
|
|
584
|
+
const { threadId } = req.params;
|
|
585
|
+
if (!validateThreadId(threadId)) {
|
|
586
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
587
|
+
}
|
|
588
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
589
|
+
const commentsFile = path.join(threadDir, "comments.json");
|
|
590
|
+
const { messageId, anchorLine, anchorEndLine, anchorText, body } = req.body;
|
|
591
|
+
if (!body || !body.trim()) {
|
|
592
|
+
return res.status(400).json({ error: "Comment body is required" });
|
|
593
|
+
}
|
|
594
|
+
let commentsData = { comments: [] };
|
|
595
|
+
try {
|
|
596
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// fresh comments
|
|
600
|
+
}
|
|
601
|
+
const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`;
|
|
602
|
+
const comment = {
|
|
603
|
+
id,
|
|
604
|
+
messageId: messageId || null,
|
|
605
|
+
anchorLine: anchorLine || null,
|
|
606
|
+
anchorEndLine: anchorEndLine || null,
|
|
607
|
+
anchorText: anchorText || null,
|
|
608
|
+
body: body.trim(),
|
|
609
|
+
createdAt: new Date().toISOString(),
|
|
610
|
+
resolved: false,
|
|
611
|
+
};
|
|
612
|
+
commentsData.comments.push(comment);
|
|
613
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
614
|
+
res.status(201).json(comment);
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
res.status(500).json({ error: e.message });
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
router.patch("/review/:threadId/comments/:commentId", express.json(), async (req, res) => {
|
|
621
|
+
try {
|
|
622
|
+
const { threadId, commentId } = req.params;
|
|
623
|
+
if (!validateThreadId(threadId)) {
|
|
624
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
625
|
+
}
|
|
626
|
+
const commentsFile = path.join(reviewDir, threadId, "comments.json");
|
|
627
|
+
let commentsData;
|
|
628
|
+
try {
|
|
629
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return res.status(404).json({ error: "Comments not found" });
|
|
633
|
+
}
|
|
634
|
+
const comment = commentsData.comments.find((c) => c.id === commentId);
|
|
635
|
+
if (!comment) {
|
|
636
|
+
return res.status(404).json({ error: "Comment not found" });
|
|
637
|
+
}
|
|
638
|
+
if (req.body.resolved !== undefined)
|
|
639
|
+
comment.resolved = req.body.resolved;
|
|
640
|
+
if (req.body.body !== undefined)
|
|
641
|
+
comment.body = req.body.body;
|
|
642
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
643
|
+
res.status(200).json(comment);
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
res.status(500).json({ error: e.message });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
router.delete("/review/:threadId/comments/:commentId", async (req, res) => {
|
|
650
|
+
try {
|
|
651
|
+
const { threadId, commentId } = req.params;
|
|
652
|
+
if (!validateThreadId(threadId)) {
|
|
653
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
654
|
+
}
|
|
655
|
+
const commentsFile = path.join(reviewDir, threadId, "comments.json");
|
|
656
|
+
let commentsData;
|
|
657
|
+
try {
|
|
658
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return res.status(404).json({ error: "Comments not found" });
|
|
662
|
+
}
|
|
663
|
+
const idx = commentsData.comments.findIndex((c) => c.id === commentId);
|
|
664
|
+
if (idx === -1) {
|
|
665
|
+
return res.status(404).json({ error: "Comment not found" });
|
|
666
|
+
}
|
|
667
|
+
commentsData.comments.splice(idx, 1);
|
|
668
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
669
|
+
res.status(200).json({ ok: true });
|
|
670
|
+
}
|
|
671
|
+
catch (e) {
|
|
672
|
+
res.status(500).json({ error: e.message });
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
router.post("/review/:threadId/mark-read", express.json(), async (req, res) => {
|
|
676
|
+
try {
|
|
677
|
+
const { threadId } = req.params;
|
|
678
|
+
if (!validateThreadId(threadId)) {
|
|
679
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
680
|
+
}
|
|
681
|
+
const threadFile = path.join(reviewDir, threadId, "thread.json");
|
|
682
|
+
let thread;
|
|
683
|
+
try {
|
|
684
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return res.status(404).json({ error: "Thread not found" });
|
|
688
|
+
}
|
|
689
|
+
const { readUntil } = req.body;
|
|
690
|
+
if (readUntil)
|
|
691
|
+
thread.readUntil = readUntil;
|
|
692
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
693
|
+
res.status(200).json({ ok: true });
|
|
694
|
+
}
|
|
695
|
+
catch (e) {
|
|
696
|
+
res.status(500).json({ error: e.message });
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
// --- Code context API for inline code popups ---
|
|
700
|
+
router.get("/api/code-context", async (req, res) => {
|
|
701
|
+
try {
|
|
702
|
+
const filePath = req.query.file;
|
|
703
|
+
if (!filePath || typeof filePath !== "string") {
|
|
704
|
+
return res.status(400).json({ error: "file parameter required" });
|
|
705
|
+
}
|
|
706
|
+
const line = parseInt(qstr(req.query.line) ?? "", 10) || 1;
|
|
707
|
+
const endLine = parseInt(qstr(req.query.endLine) ?? "", 10) || line;
|
|
708
|
+
const context = Math.min(parseInt(qstr(req.query.context) ?? "", 10) || 20, 200);
|
|
709
|
+
const { resolved, stripped } = await safeRealpath(ctx.repoRootReal, filePath);
|
|
710
|
+
const st = await statSafe(resolved);
|
|
711
|
+
if (!st || !st.isFile) {
|
|
712
|
+
return res.status(404).json({ error: "File not found" });
|
|
713
|
+
}
|
|
714
|
+
if (st.size > 2 * 1024 * 1024) {
|
|
715
|
+
return res.status(400).json({ error: "File too large" });
|
|
716
|
+
}
|
|
717
|
+
const raw = await fs.readFile(resolved, "utf8");
|
|
718
|
+
const allLines = raw.split("\n");
|
|
719
|
+
const startLine = Math.max(1, Math.min(line, endLine) - context);
|
|
720
|
+
const stopLine = Math.min(allLines.length, Math.max(line, endLine) + context);
|
|
721
|
+
const snippet = allLines.slice(startLine - 1, stopLine);
|
|
722
|
+
const ext = path.extname(stripped).slice(1);
|
|
723
|
+
// Get diff for this file (against HEAD)
|
|
724
|
+
let diff = null;
|
|
725
|
+
const diffResult = await execGit(ctx.repoRootReal, ["diff", "HEAD", "--", stripped], 256 * 1024);
|
|
726
|
+
if (diffResult.output) {
|
|
727
|
+
diff = diffResult.output;
|
|
728
|
+
}
|
|
729
|
+
res.json({
|
|
730
|
+
file: toPosixPath(stripped),
|
|
731
|
+
startLine,
|
|
732
|
+
stopLine,
|
|
733
|
+
highlightStart: Math.min(line, endLine),
|
|
734
|
+
highlightEnd: Math.max(line, endLine),
|
|
735
|
+
lines: snippet,
|
|
736
|
+
language: ext,
|
|
737
|
+
totalLines: allLines.length,
|
|
738
|
+
diff,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
catch (e) {
|
|
742
|
+
const err = e;
|
|
743
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
router.get(["/raw/*", "/raw"], async (req, res) => {
|
|
747
|
+
try {
|
|
748
|
+
const p = req.params[0] ?? "";
|
|
749
|
+
const { resolved } = await safeRealpath(ctx.repoRootReal, p);
|
|
750
|
+
const st = await statSafe(resolved);
|
|
751
|
+
if (st === null) {
|
|
752
|
+
const err = new Error("Permission denied");
|
|
753
|
+
err.statusCode = 403;
|
|
754
|
+
throw err;
|
|
755
|
+
}
|
|
756
|
+
if (!st.isFile) {
|
|
757
|
+
const err = new Error("Not a file");
|
|
758
|
+
err.statusCode = 400;
|
|
759
|
+
throw err;
|
|
760
|
+
}
|
|
761
|
+
const contentType = mime.contentType(path.extname(resolved)) || "application/octet-stream";
|
|
762
|
+
res.setHeader("Content-Type", contentType);
|
|
763
|
+
res.sendFile(resolved);
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
const err = e;
|
|
767
|
+
res
|
|
768
|
+
.status(err.statusCode || 500)
|
|
769
|
+
.send(errorPage("Error", e.message ?? "Error"));
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
return router;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Build a parent router serving every repo in the session under
|
|
776
|
+
* `/r/:repoId/...`. Each repo's child router is built lazily and cached.
|
|
777
|
+
*/
|
|
778
|
+
export function createReposRouter(session) {
|
|
779
|
+
const cache = new WeakMap();
|
|
780
|
+
const parent = express.Router();
|
|
781
|
+
parent.use("/:repoId", (req, res, next) => {
|
|
782
|
+
const ctx = session.getRepo(req.params.repoId);
|
|
783
|
+
if (!ctx) {
|
|
784
|
+
// Friendly fallback: show the session page (lists available repos) so a
|
|
785
|
+
// stale bookmark or a removed repo doesn't dead-end on a bare error.
|
|
786
|
+
return res.status(404).send(renderSessionPage({
|
|
787
|
+
repos: session.listRepos(),
|
|
788
|
+
notice: `Repository "${req.params.repoId}" is not in this session.`,
|
|
789
|
+
canManage: isLoopbackAddress(req.socket.remoteAddress),
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
let child = cache.get(ctx);
|
|
793
|
+
if (!child) {
|
|
794
|
+
child = buildRepoRouter(ctx, `/r/${ctx.id}`, session);
|
|
795
|
+
cache.set(ctx, child);
|
|
796
|
+
}
|
|
797
|
+
return child(req, res, next);
|
|
798
|
+
});
|
|
799
|
+
return parent;
|
|
800
|
+
}
|
|
801
|
+
//# sourceMappingURL=repo-router.js.map
|