seendiff 0.0.2
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/README.md +124 -0
- package/bin/seendiff.js +9 -0
- package/package.json +38 -0
- package/src/cli.js +216 -0
- package/src/git.js +615 -0
- package/src/highlight.js +220 -0
- package/src/server.js +580 -0
- package/src/store.js +128 -0
- package/src/theme-base.css +482 -0
- package/src/theme.js +14 -0
- package/src/walkthrough.js +338 -0
- package/static/fonts/JetBrainsMono.woff2 +0 -0
- package/static/fonts/LICENCE-UbuntuSansMono.txt +96 -0
- package/static/fonts/LICENSE-JetBrainsMono.txt +93 -0
- package/static/fonts/README.md +29 -0
- package/static/fonts/UbuntuSansMono.woff2 +0 -0
- package/static/index.html +3175 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import fastifyCompress from "@fastify/compress";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import * as G from "./git.js";
|
|
10
|
+
import * as H from "./highlight.js";
|
|
11
|
+
import * as store from "./store.js";
|
|
12
|
+
import * as W from "./walkthrough.js";
|
|
13
|
+
import { themeCss } from "./theme.js";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const STATIC = path.join(__dirname, "..", "static");
|
|
17
|
+
|
|
18
|
+
const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
19
|
+
if (process.env.SEENDIFF_ALLOW_HOST) {
|
|
20
|
+
ALLOWED_HOSTS.add(process.env.SEENDIFF_ALLOW_HOST);
|
|
21
|
+
}
|
|
22
|
+
const CACHE_PRUNE_DAYS = 30;
|
|
23
|
+
const SEARCH_LIMIT = 500;
|
|
24
|
+
|
|
25
|
+
export function cacheRoot() {
|
|
26
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
27
|
+
return path.join(base, "seendiff");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function pruneCache(root, days = CACHE_PRUNE_DAYS) {
|
|
31
|
+
if (!existsSync(root)) return;
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const cutoff = Date.now() - days * 86400 * 1000;
|
|
39
|
+
for (const d of entries) {
|
|
40
|
+
if (!d.isDirectory()) continue;
|
|
41
|
+
const full = path.join(root, d.name);
|
|
42
|
+
try {
|
|
43
|
+
const st = statSync(full);
|
|
44
|
+
if (st.mtimeMs < cutoff) rmSync(full, { recursive: true, force: true });
|
|
45
|
+
} catch {
|
|
46
|
+
// best-effort
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function weight(b) {
|
|
52
|
+
return G.decorateBlock(b).added + G.decorateBlock(b).removed || 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function counts(fd, states) {
|
|
56
|
+
let seen = 0;
|
|
57
|
+
let reviewed = 0;
|
|
58
|
+
let wTot = 0;
|
|
59
|
+
let wSeen = 0;
|
|
60
|
+
let wRev = 0;
|
|
61
|
+
for (const b of fd.blocks) {
|
|
62
|
+
const w = weight(b);
|
|
63
|
+
wTot += w;
|
|
64
|
+
const [s, r] = states.get(`${fd.path}\u0000${b.hunkId}`) || [null, null];
|
|
65
|
+
if (r) {
|
|
66
|
+
reviewed++;
|
|
67
|
+
wRev += w;
|
|
68
|
+
} else if (s) {
|
|
69
|
+
seen++;
|
|
70
|
+
wSeen += w;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { n: fd.blocks.length, seen, reviewed, wTot, wSeen, wRev };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class AppState {
|
|
77
|
+
constructor({ repo, baseRef, noHighlight = false, autoSeen = true, sectionMin = G.SECTION_MIN, sectionMax = G.SECTION_MAX, dbPath = null, wtFile = null }) {
|
|
78
|
+
this.repo = repo;
|
|
79
|
+
this.baseRef = baseRef;
|
|
80
|
+
this.noHighlight = noHighlight;
|
|
81
|
+
this.autoSeen = autoSeen;
|
|
82
|
+
this.sectionMin = sectionMin;
|
|
83
|
+
this.sectionMax = sectionMax;
|
|
84
|
+
this.dbPath = dbPath;
|
|
85
|
+
|
|
86
|
+
this.mbSha = "";
|
|
87
|
+
this.headSha = "";
|
|
88
|
+
this.baseDate = "";
|
|
89
|
+
this.branch = null;
|
|
90
|
+
this.scope = "";
|
|
91
|
+
this.files = [];
|
|
92
|
+
this.byPath = new Map();
|
|
93
|
+
this.dirty = [];
|
|
94
|
+
this.conflicted = [];
|
|
95
|
+
this.fileCache = new Map();
|
|
96
|
+
|
|
97
|
+
this.wtFile = wtFile;
|
|
98
|
+
this.wt = null;
|
|
99
|
+
this.wtEmpty = true;
|
|
100
|
+
this.wtError = null;
|
|
101
|
+
this.wtAttemptMtime = -1.0;
|
|
102
|
+
this.wtPaths = new Set();
|
|
103
|
+
this.wtCache = new Map();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
hunkIds() {
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const [p, fd] of this.byPath) {
|
|
109
|
+
out[p] = new Set(fd.blocks.map((b) => b.hunkId));
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_recomputeWtPaths() {
|
|
115
|
+
if (!this.wt) {
|
|
116
|
+
this.wtPaths = new Set();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const wtPaths = W.walkthroughPaths(this.wt);
|
|
120
|
+
this.wtPaths = new Set([...wtPaths].filter((p) => !this.byPath.has(p)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
loadWalkthrough() {
|
|
124
|
+
if (!this.wtFile) return;
|
|
125
|
+
let mtime;
|
|
126
|
+
try {
|
|
127
|
+
mtime = statSync(this.wtFile).mtimeMs;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (e.code === "ENOENT") {
|
|
130
|
+
this.wt = null;
|
|
131
|
+
this.wtError = null;
|
|
132
|
+
this.wtEmpty = true;
|
|
133
|
+
this.wtAttemptMtime = -1.0;
|
|
134
|
+
this._recomputeWtPaths();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.wtError = `cannot stat walkthrough: ${e.message}`;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (mtime === this.wtAttemptMtime) return;
|
|
141
|
+
this.wtAttemptMtime = mtime;
|
|
142
|
+
try {
|
|
143
|
+
this.wt = W.load(this.wtFile, this.repo, this.hunkIds());
|
|
144
|
+
this.wtEmpty = this.wt === null;
|
|
145
|
+
this.wtError = null;
|
|
146
|
+
this.wtCache.clear();
|
|
147
|
+
this._recomputeWtPaths();
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof W.WalkthroughError) {
|
|
150
|
+
this.wtError = e.problems.join("\n");
|
|
151
|
+
} else {
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
refresh() {
|
|
158
|
+
this.mbSha = G.mergeBase(this.repo, this.baseRef);
|
|
159
|
+
this.headSha = G.revSha(this.repo);
|
|
160
|
+
this.baseDate = G.commitDate(this.repo, this.mbSha);
|
|
161
|
+
this.branch = G.branchName(this.repo);
|
|
162
|
+
this.scope = store.scopeKey(this.repo, this.baseRef, this.branch, this.mbSha);
|
|
163
|
+
this.files = G.expandSubmodules(
|
|
164
|
+
this.repo,
|
|
165
|
+
G.identityDiff(this.repo, this.mbSha, "HEAD", this.sectionMin, this.sectionMax),
|
|
166
|
+
this.sectionMin,
|
|
167
|
+
this.sectionMax
|
|
168
|
+
);
|
|
169
|
+
this.byPath = new Map(this.files.map((f) => [f.path, f]));
|
|
170
|
+
const [dirty, conflicted] = G.dirtyAndConflicted(this.repo);
|
|
171
|
+
this.dirty = dirty;
|
|
172
|
+
this.conflicted = conflicted;
|
|
173
|
+
this.fileCache.clear();
|
|
174
|
+
this._recomputeWtPaths();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
maybeRefresh() {
|
|
178
|
+
if (G.revSha(this.repo) !== this.headSha) {
|
|
179
|
+
this.refresh();
|
|
180
|
+
} else {
|
|
181
|
+
const [dirty, conflicted] = G.dirtyAndConflicted(this.repo);
|
|
182
|
+
this.dirty = dirty;
|
|
183
|
+
this.conflicted = conflicted;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
diskCacheDir() {
|
|
188
|
+
return path.join(cacheRoot(), `${this.mbSha.slice(0, 12)}-${this.headSha.slice(0, 12)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function fileStates(st, db) {
|
|
193
|
+
return store.getStates(db, st.scope);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildFilePayload(st, fd) {
|
|
197
|
+
const key = createHash("sha256").update(fd.path).digest("hex").slice(0, 16) + ".json";
|
|
198
|
+
const disk = path.join(st.diskCacheDir(), key);
|
|
199
|
+
if (st.fileCache.has(fd.path)) return st.fileCache.get(fd.path);
|
|
200
|
+
if (existsSync(disk)) {
|
|
201
|
+
try {
|
|
202
|
+
const payload = JSON.parse(readFileSync(disk, "utf8"));
|
|
203
|
+
st.fileCache.set(fd.path, payload);
|
|
204
|
+
return payload;
|
|
205
|
+
} catch {
|
|
206
|
+
// fall through to rebuild
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rows = G.displayRows(st.repo, st.mbSha, fd);
|
|
211
|
+
const toks = st.noHighlight ? null : H.highlightRows(rows, fd.path);
|
|
212
|
+
const skipReason = st.noHighlight ? null : H.guard(rows);
|
|
213
|
+
const lines = rows.map((r, i) => {
|
|
214
|
+
const line = { old: r.old, new: r.new, type: r.type };
|
|
215
|
+
if (r.hunkId) line.h = r.hunkId;
|
|
216
|
+
if (toks !== null) line.t = toks[i];
|
|
217
|
+
else line.text = r.text;
|
|
218
|
+
return line;
|
|
219
|
+
});
|
|
220
|
+
const payload = {
|
|
221
|
+
path: fd.path,
|
|
222
|
+
old_path: fd.oldPath,
|
|
223
|
+
status: fd.status,
|
|
224
|
+
lang: toks !== null ? H.langFor(fd.path) || "text" : "text",
|
|
225
|
+
total_lines: rows.length,
|
|
226
|
+
highlighted: toks !== null,
|
|
227
|
+
skip_reason: skipReason,
|
|
228
|
+
block_meta: fd.blocks.map((b) => {
|
|
229
|
+
const db2 = G.decorateBlock(b);
|
|
230
|
+
return {
|
|
231
|
+
hunk_id: b.hunkId,
|
|
232
|
+
start: b.runs.length ? db2.newStart : 1,
|
|
233
|
+
end: b.runs.length ? db2.newEnd : Math.max(1, rows.length),
|
|
234
|
+
added: db2.added,
|
|
235
|
+
removed: db2.removed,
|
|
236
|
+
kind: b.runs.length ? db2.kind : "meta",
|
|
237
|
+
};
|
|
238
|
+
}),
|
|
239
|
+
lines,
|
|
240
|
+
};
|
|
241
|
+
st.fileCache.set(fd.path, payload);
|
|
242
|
+
try {
|
|
243
|
+
mkdirSync(path.dirname(disk), { recursive: true });
|
|
244
|
+
writeFileSync(disk, JSON.stringify(payload));
|
|
245
|
+
} catch {
|
|
246
|
+
// best-effort disk cache
|
|
247
|
+
}
|
|
248
|
+
return payload;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function lineText(line) {
|
|
252
|
+
if (line.t != null) return line.t.map((piece) => piece[1]).join("");
|
|
253
|
+
return line.text || "";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function createApp(st) {
|
|
257
|
+
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024 * 64 });
|
|
258
|
+
await app.register(fastifyCompress, { threshold: 2048, encodings: ["gzip", "deflate", "br"] });
|
|
259
|
+
|
|
260
|
+
app.addContentTypeParser("*", { parseAs: "string" }, (request, body, done) => {
|
|
261
|
+
if (!body) return done(null, {});
|
|
262
|
+
try {
|
|
263
|
+
done(null, JSON.parse(body));
|
|
264
|
+
} catch {
|
|
265
|
+
done(null, {});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const db = store.openDb(st.dbPath);
|
|
270
|
+
st.refresh();
|
|
271
|
+
pruneCache(cacheRoot());
|
|
272
|
+
const themeCssStr = themeCss();
|
|
273
|
+
|
|
274
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
275
|
+
const hostHeader = request.headers.host || "";
|
|
276
|
+
const host = hostHeader.split(":")[0];
|
|
277
|
+
if (!ALLOWED_HOSTS.has(host)) {
|
|
278
|
+
reply.code(403).type("text/plain").send("forbidden host");
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
app.get("/", { compress: false }, async (request, reply) => {
|
|
283
|
+
let html = readFileSync(path.join(STATIC, "index.html"), "utf8");
|
|
284
|
+
html = html.replace("__PREFS__", JSON.stringify(store.getPrefs(db)));
|
|
285
|
+
html = html.replace(
|
|
286
|
+
'<link rel="stylesheet" href="/theme.css">',
|
|
287
|
+
`<style>${themeCssStr}</style>`
|
|
288
|
+
);
|
|
289
|
+
reply.header("Cache-Control", "no-store").type("text/html").send(html);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app.get("/api/prefs", async () => store.getPrefs(db));
|
|
293
|
+
|
|
294
|
+
app.post("/api/prefs", async (request, reply) => {
|
|
295
|
+
const body = request.body || {};
|
|
296
|
+
const { key, value } = body;
|
|
297
|
+
if (typeof key !== "string" || typeof value !== "string") {
|
|
298
|
+
reply.code(400);
|
|
299
|
+
return { error: "key and value must be strings" };
|
|
300
|
+
}
|
|
301
|
+
store.setPref(db, key, value);
|
|
302
|
+
return { ok: true };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
app.get("/theme.css", async (request, reply) => {
|
|
306
|
+
reply.header("Cache-Control", "max-age=86400").type("text/css").send(themeCssStr);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
app.get("/fonts/:name", async (request, reply) => {
|
|
310
|
+
const name = request.params.name;
|
|
311
|
+
const fontsDir = path.resolve(path.join(STATIC, "fonts"));
|
|
312
|
+
const p = path.resolve(path.join(fontsDir, name));
|
|
313
|
+
if (!p.startsWith(fontsDir + path.sep) || !existsSync(p) || !statSync(p).isFile()) {
|
|
314
|
+
reply.code(404);
|
|
315
|
+
return { error: "not found" };
|
|
316
|
+
}
|
|
317
|
+
reply.header("Cache-Control", "max-age=31536000");
|
|
318
|
+
reply.send(readFileSync(p));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
app.get("/api/files", async () => {
|
|
322
|
+
st.maybeRefresh();
|
|
323
|
+
const states = fileStates(st, db);
|
|
324
|
+
const files = [];
|
|
325
|
+
let tot = 0;
|
|
326
|
+
let totSeen = 0;
|
|
327
|
+
let totRev = 0;
|
|
328
|
+
let wTot = 0;
|
|
329
|
+
let wSeen = 0;
|
|
330
|
+
let wRev = 0;
|
|
331
|
+
for (const fd of st.files) {
|
|
332
|
+
const c = counts(fd, states);
|
|
333
|
+
tot += c.n;
|
|
334
|
+
totSeen += c.seen;
|
|
335
|
+
totRev += c.reviewed;
|
|
336
|
+
wTot += c.wTot;
|
|
337
|
+
wSeen += c.wSeen;
|
|
338
|
+
wRev += c.wRev;
|
|
339
|
+
files.push({
|
|
340
|
+
path: fd.path,
|
|
341
|
+
old_path: fd.oldPath,
|
|
342
|
+
status: fd.status,
|
|
343
|
+
added: fd.added,
|
|
344
|
+
removed: fd.removed,
|
|
345
|
+
blocks: c.n,
|
|
346
|
+
seen: c.seen,
|
|
347
|
+
reviewed: c.reviewed,
|
|
348
|
+
weight: c.wTot,
|
|
349
|
+
seen_w: c.wSeen,
|
|
350
|
+
reviewed_w: c.wRev,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
scope: st.scope,
|
|
355
|
+
branch: st.branch,
|
|
356
|
+
base_ref: st.baseRef,
|
|
357
|
+
base_sha: st.mbSha.slice(0, 12),
|
|
358
|
+
base_date: st.baseDate,
|
|
359
|
+
head_sha: st.headSha.slice(0, 12),
|
|
360
|
+
dirty_files: st.dirty,
|
|
361
|
+
conflicted_files: st.conflicted,
|
|
362
|
+
auto_seen: st.autoSeen,
|
|
363
|
+
stats: { blocks: tot, seen: totSeen, reviewed: totRev, weight: wTot, seen_w: wSeen, reviewed_w: wRev },
|
|
364
|
+
files,
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
function worktreePayload(filePath) {
|
|
369
|
+
let mtime;
|
|
370
|
+
try {
|
|
371
|
+
mtime = statSync(path.join(st.repo, filePath)).mtimeMs;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
const err = new Error(`cannot stat ${filePath}: ${e.message}`);
|
|
374
|
+
err.statusCode = 404;
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
const key = `${filePath}\u0000${mtime}`;
|
|
378
|
+
if (st.wtCache.has(key)) return st.wtCache.get(key);
|
|
379
|
+
let rows;
|
|
380
|
+
try {
|
|
381
|
+
rows = G.worktreeRows(st.repo, filePath);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
const err = new Error(e.message);
|
|
384
|
+
err.statusCode = 404;
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
const toks = st.noHighlight ? null : H.highlightRows(rows, filePath);
|
|
388
|
+
const skipReason = st.noHighlight ? null : H.guard(rows);
|
|
389
|
+
const lines = rows.map((r, i) => {
|
|
390
|
+
const line = { old: r.old, new: r.new, type: r.type };
|
|
391
|
+
if (toks !== null) line.t = toks[i];
|
|
392
|
+
else line.text = r.text;
|
|
393
|
+
return line;
|
|
394
|
+
});
|
|
395
|
+
const payload = {
|
|
396
|
+
path: filePath,
|
|
397
|
+
old_path: null,
|
|
398
|
+
status: "worktree",
|
|
399
|
+
lang: toks !== null ? H.langFor(filePath) || "text" : "text",
|
|
400
|
+
total_lines: rows.length,
|
|
401
|
+
highlighted: toks !== null,
|
|
402
|
+
skip_reason: skipReason,
|
|
403
|
+
blocks: [],
|
|
404
|
+
lines,
|
|
405
|
+
};
|
|
406
|
+
for (const k of [...st.wtCache.keys()]) {
|
|
407
|
+
if (k.startsWith(`${filePath}\u0000`)) st.wtCache.delete(k);
|
|
408
|
+
}
|
|
409
|
+
st.wtCache.set(key, payload);
|
|
410
|
+
return payload;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
app.get("/api/walkthrough", async (request, reply) => {
|
|
414
|
+
if (!st.wtFile) {
|
|
415
|
+
reply.code(404);
|
|
416
|
+
return { error: "no walkthrough loaded" };
|
|
417
|
+
}
|
|
418
|
+
st.maybeRefresh();
|
|
419
|
+
st.loadWalkthrough();
|
|
420
|
+
if (st.wt === null && st.wtEmpty) {
|
|
421
|
+
return { empty: true };
|
|
422
|
+
}
|
|
423
|
+
if (st.wt === null) {
|
|
424
|
+
reply.code(422);
|
|
425
|
+
return { error: "walkthrough failed to load:\n" + (st.wtError || "?") };
|
|
426
|
+
}
|
|
427
|
+
const out = W.walkthroughToJson(st.wt);
|
|
428
|
+
out.error = st.wtError;
|
|
429
|
+
out.wt_paths = [...st.wtPaths].sort();
|
|
430
|
+
return out;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
app.post("/api/walkthrough/dismiss", async (request, reply) => {
|
|
434
|
+
if (!st.wtFile) {
|
|
435
|
+
reply.code(404);
|
|
436
|
+
return { error: "no walkthrough loaded" };
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
writeFileSync(st.wtFile, "{}\n");
|
|
440
|
+
} catch (e) {
|
|
441
|
+
reply.code(500);
|
|
442
|
+
return { error: `cannot empty walkthrough: ${e.message}` };
|
|
443
|
+
}
|
|
444
|
+
st.loadWalkthrough();
|
|
445
|
+
return { empty: true };
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
app.get("/api/file", async (request, reply) => {
|
|
449
|
+
const filePath = request.query.path;
|
|
450
|
+
const fd = st.byPath.get(filePath);
|
|
451
|
+
if (fd === undefined) {
|
|
452
|
+
if (st.wtPaths.has(filePath)) {
|
|
453
|
+
try {
|
|
454
|
+
return worktreePayload(filePath);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
reply.code(e.statusCode || 500);
|
|
457
|
+
return { error: e.message };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
reply.code(404);
|
|
461
|
+
return { error: `not in diff: ${filePath}` };
|
|
462
|
+
}
|
|
463
|
+
const payload = { ...buildFilePayload(st, fd) };
|
|
464
|
+
const states = fileStates(st, db);
|
|
465
|
+
const blockMeta = payload.block_meta;
|
|
466
|
+
delete payload.block_meta;
|
|
467
|
+
payload.blocks = blockMeta.map((bm) => {
|
|
468
|
+
const [s, r] = states.get(`${filePath}\u0000${bm.hunk_id}`) || [null, null];
|
|
469
|
+
return { ...bm, seen: Boolean(s), reviewed: Boolean(r) };
|
|
470
|
+
});
|
|
471
|
+
return payload;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
app.get("/api/search", async (request, reply) => {
|
|
475
|
+
const q = request.query.q || "";
|
|
476
|
+
const regex = request.query.regex === "true" || request.query.regex === true;
|
|
477
|
+
const caseSensitive = request.query.case === "true" || request.query.case === true;
|
|
478
|
+
let limit = request.query.limit !== undefined ? parseInt(request.query.limit, 10) : SEARCH_LIMIT;
|
|
479
|
+
if (!q) return { q, total: 0, truncated: false, files: [] };
|
|
480
|
+
let rx;
|
|
481
|
+
try {
|
|
482
|
+
const pattern = regex ? q : q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
rx = new RegExp(pattern, caseSensitive ? "" : "i");
|
|
484
|
+
} catch (e) {
|
|
485
|
+
reply.code(400);
|
|
486
|
+
return { error: `bad regex: ${e.message}` };
|
|
487
|
+
}
|
|
488
|
+
limit = Math.max(1, Math.min(limit || SEARCH_LIMIT, 5000));
|
|
489
|
+
st.maybeRefresh();
|
|
490
|
+
const out = [];
|
|
491
|
+
let total = 0;
|
|
492
|
+
let truncated = false;
|
|
493
|
+
for (const fd of st.files) {
|
|
494
|
+
if (truncated) break;
|
|
495
|
+
if (fd.status === "binary" || fd.status === "meta") continue;
|
|
496
|
+
const hits = [];
|
|
497
|
+
const payload = buildFilePayload(st, fd);
|
|
498
|
+
for (let i = 0; i < payload.lines.length; i++) {
|
|
499
|
+
const line = payload.lines[i];
|
|
500
|
+
const text = lineText(line);
|
|
501
|
+
const m = rx.exec(text);
|
|
502
|
+
if (m === null) continue;
|
|
503
|
+
hits.push({
|
|
504
|
+
line: i,
|
|
505
|
+
new: line.new,
|
|
506
|
+
old: line.old,
|
|
507
|
+
type: line.type,
|
|
508
|
+
text: text.slice(0, 400),
|
|
509
|
+
start: m.index,
|
|
510
|
+
end: m.index + m[0].length,
|
|
511
|
+
});
|
|
512
|
+
total++;
|
|
513
|
+
if (total >= limit) {
|
|
514
|
+
truncated = true;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (hits.length) out.push({ path: fd.path, matches: hits });
|
|
519
|
+
}
|
|
520
|
+
return { q, total, truncated, files: out };
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
function validBlocks(items) {
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const it of items || []) {
|
|
526
|
+
const fd = st.byPath.get(it.path || "");
|
|
527
|
+
if (fd && fd.blocks.some((b) => b.hunkId === it.hunk_id)) {
|
|
528
|
+
out.push([it.path, it.hunk_id]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
app.post("/api/seen", async (request) => {
|
|
535
|
+
const body = request.body || {};
|
|
536
|
+
const blocks = validBlocks(body.blocks);
|
|
537
|
+
if (blocks.length) store.markSeen(db, st.scope, blocks);
|
|
538
|
+
return { marked: blocks.length };
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
app.post("/api/reviewed", async (request, reply) => {
|
|
542
|
+
const body = request.body || {};
|
|
543
|
+
let blocks;
|
|
544
|
+
if (body.file !== undefined) {
|
|
545
|
+
const fd = st.byPath.get(body.file);
|
|
546
|
+
if (fd === undefined) {
|
|
547
|
+
reply.code(404);
|
|
548
|
+
return { error: `not in diff: ${body.file}` };
|
|
549
|
+
}
|
|
550
|
+
blocks = fd.blocks.map((b) => [fd.path, b.hunkId]);
|
|
551
|
+
} else {
|
|
552
|
+
blocks = validBlocks(body.blocks);
|
|
553
|
+
}
|
|
554
|
+
if (blocks.length) store.markReviewed(db, st.scope, blocks);
|
|
555
|
+
return { marked: blocks.length };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
app.delete("/api/reviewed", async (request, reply) => {
|
|
559
|
+
const { path: qPath, hunk_id: hunkId, file, all } = request.query;
|
|
560
|
+
const isAll = all === "true" || all === true;
|
|
561
|
+
if (isAll) {
|
|
562
|
+
store.unmarkReviewed(db, st.scope, { all: true });
|
|
563
|
+
} else if (file !== undefined) {
|
|
564
|
+
store.unmarkReviewed(db, st.scope, { filePath: file });
|
|
565
|
+
} else if (qPath && hunkId) {
|
|
566
|
+
store.unmarkReviewed(db, st.scope, { blocks: [[qPath, hunkId]] });
|
|
567
|
+
} else {
|
|
568
|
+
reply.code(400);
|
|
569
|
+
return { error: "need ?all=, ?file= or ?path=&hunk_id=" };
|
|
570
|
+
}
|
|
571
|
+
return { ok: true };
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
app.delete("/api/state", async () => {
|
|
575
|
+
store.clearScope(db, st.scope);
|
|
576
|
+
return { ok: true };
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return app;
|
|
580
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
export const PRUNE_DAYS = 60;
|
|
7
|
+
|
|
8
|
+
const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS block_state (
|
|
10
|
+
scope TEXT NOT NULL,
|
|
11
|
+
path TEXT NOT NULL,
|
|
12
|
+
hunk_id TEXT NOT NULL,
|
|
13
|
+
seen_at TEXT,
|
|
14
|
+
reviewed_at TEXT,
|
|
15
|
+
PRIMARY KEY (scope, path, hunk_id)
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS bs_scope ON block_state(scope);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS prefs (
|
|
19
|
+
key TEXT PRIMARY KEY,
|
|
20
|
+
value TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export function defaultDbPath() {
|
|
25
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
26
|
+
const d = path.join(stateHome, "seendiff");
|
|
27
|
+
mkdirSync(d, { recursive: true });
|
|
28
|
+
return path.join(d, "state.db");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scopeKey(repo, baseRef, branch, mbSha) {
|
|
32
|
+
return `${repo}|${baseRef}|${branch || mbSha}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function now() {
|
|
36
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function openDb(dbPath = null) {
|
|
40
|
+
const db = new DatabaseSync(dbPath || defaultDbPath());
|
|
41
|
+
db.exec(SCHEMA);
|
|
42
|
+
prune(db);
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function prune(db, days = PRUNE_DAYS) {
|
|
47
|
+
const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
|
48
|
+
db.prepare(
|
|
49
|
+
"DELETE FROM block_state WHERE COALESCE(reviewed_at, '') < ? AND COALESCE(seen_at, '') < ?"
|
|
50
|
+
).run(cutoff, cutoff);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getStates(db, scope) {
|
|
54
|
+
const rows = db
|
|
55
|
+
.prepare("SELECT path, hunk_id, seen_at, reviewed_at FROM block_state WHERE scope=?")
|
|
56
|
+
.all(scope);
|
|
57
|
+
const out = new Map();
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
out.set(`${r.path}\u0000${r.hunk_id}`, [r.seen_at ?? null, r.reviewed_at ?? null]);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function markSeen(db, scope, blocks) {
|
|
65
|
+
const t = now();
|
|
66
|
+
const stmt = db.prepare(
|
|
67
|
+
`INSERT INTO block_state (scope, path, hunk_id, seen_at) VALUES (?,?,?,?)
|
|
68
|
+
ON CONFLICT(scope, path, hunk_id) DO UPDATE SET
|
|
69
|
+
seen_at = COALESCE(seen_at, excluded.seen_at)`
|
|
70
|
+
);
|
|
71
|
+
db.exec("BEGIN");
|
|
72
|
+
try {
|
|
73
|
+
for (const [p, h] of blocks) stmt.run(scope, p, h, t);
|
|
74
|
+
db.exec("COMMIT");
|
|
75
|
+
} catch (e) {
|
|
76
|
+
db.exec("ROLLBACK");
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function markReviewed(db, scope, blocks) {
|
|
82
|
+
const t = now();
|
|
83
|
+
const stmt = db.prepare(
|
|
84
|
+
`INSERT INTO block_state (scope, path, hunk_id, seen_at, reviewed_at) VALUES (?,?,?,?,?)
|
|
85
|
+
ON CONFLICT(scope, path, hunk_id) DO UPDATE SET
|
|
86
|
+
seen_at = COALESCE(seen_at, excluded.seen_at),
|
|
87
|
+
reviewed_at = excluded.reviewed_at`
|
|
88
|
+
);
|
|
89
|
+
db.exec("BEGIN");
|
|
90
|
+
try {
|
|
91
|
+
for (const [p, h] of blocks) stmt.run(scope, p, h, t, t);
|
|
92
|
+
db.exec("COMMIT");
|
|
93
|
+
} catch (e) {
|
|
94
|
+
db.exec("ROLLBACK");
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function unmarkReviewed(db, scope, { blocks = null, filePath = null, all = false } = {}) {
|
|
100
|
+
if (all) {
|
|
101
|
+
db.prepare("UPDATE block_state SET reviewed_at=NULL WHERE scope=?").run(scope);
|
|
102
|
+
} else if (filePath !== null) {
|
|
103
|
+
db.prepare("UPDATE block_state SET reviewed_at=NULL WHERE scope=? AND path=?").run(scope, filePath);
|
|
104
|
+
} else if (blocks && blocks.length) {
|
|
105
|
+
const stmt = db.prepare(
|
|
106
|
+
"UPDATE block_state SET reviewed_at=NULL WHERE scope=? AND path=? AND hunk_id=?"
|
|
107
|
+
);
|
|
108
|
+
for (const [p, h] of blocks) stmt.run(scope, p, h);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getPrefs(db) {
|
|
113
|
+
const rows = db.prepare("SELECT key, value FROM prefs").all();
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const r of rows) out[r.key] = r.value;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function setPref(db, key, value) {
|
|
120
|
+
db.prepare(
|
|
121
|
+
`INSERT INTO prefs (key, value) VALUES (?,?)
|
|
122
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
123
|
+
).run(key, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clearScope(db, scope) {
|
|
127
|
+
db.prepare("DELETE FROM block_state WHERE scope=?").run(scope);
|
|
128
|
+
}
|