glassbox 0.1.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/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cli.js +3689 -0
- package/package.json +53 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { Hono as Hono3 } from "hono";
|
|
5
|
+
import { serve } from "@hono/node-server";
|
|
6
|
+
import { exec } from "child_process";
|
|
7
|
+
|
|
8
|
+
// src/routes/api.ts
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
|
|
11
|
+
// src/db/connection.ts
|
|
12
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { mkdirSync } from "fs";
|
|
16
|
+
var dataDir = join(homedir(), ".glassbox", "data");
|
|
17
|
+
mkdirSync(dataDir, { recursive: true });
|
|
18
|
+
var db = null;
|
|
19
|
+
async function getDb() {
|
|
20
|
+
if (db) return db;
|
|
21
|
+
db = new PGlite(join(dataDir, "reviews"));
|
|
22
|
+
await db.waitReady;
|
|
23
|
+
await initSchema(db);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
async function initSchema(db2) {
|
|
27
|
+
await db2.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS reviews (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
repo_path TEXT NOT NULL,
|
|
31
|
+
repo_name TEXT NOT NULL,
|
|
32
|
+
mode TEXT NOT NULL,
|
|
33
|
+
mode_args TEXT,
|
|
34
|
+
head_commit TEXT,
|
|
35
|
+
status TEXT NOT NULL DEFAULT 'in_progress',
|
|
36
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
37
|
+
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS review_files (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
43
|
+
file_path TEXT NOT NULL,
|
|
44
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
45
|
+
diff_data TEXT,
|
|
46
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_review_files_review ON review_files(review_id);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
review_file_id TEXT NOT NULL REFERENCES review_files(id) ON DELETE CASCADE,
|
|
54
|
+
line_number INTEGER NOT NULL,
|
|
55
|
+
side TEXT NOT NULL DEFAULT 'new',
|
|
56
|
+
category TEXT NOT NULL DEFAULT 'note',
|
|
57
|
+
content TEXT NOT NULL,
|
|
58
|
+
is_stale BOOLEAN NOT NULL DEFAULT FALSE,
|
|
59
|
+
original_content TEXT,
|
|
60
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
61
|
+
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_file ON annotations(review_file_id);
|
|
65
|
+
`);
|
|
66
|
+
try {
|
|
67
|
+
await db2.exec("ALTER TABLE reviews ADD COLUMN head_commit TEXT");
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await db2.exec("ALTER TABLE annotations ADD COLUMN is_stale BOOLEAN NOT NULL DEFAULT FALSE");
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await db2.exec("ALTER TABLE annotations ADD COLUMN original_content TEXT");
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/db/queries.ts
|
|
81
|
+
function generateId() {
|
|
82
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
83
|
+
}
|
|
84
|
+
async function createReview(repoPath, repoName, mode, modeArgs, headCommit) {
|
|
85
|
+
const db2 = await getDb();
|
|
86
|
+
const id = generateId();
|
|
87
|
+
const result = await db2.query(
|
|
88
|
+
`INSERT INTO reviews (id, repo_path, repo_name, mode, mode_args, head_commit)
|
|
89
|
+
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
|
90
|
+
[id, repoPath, repoName, mode, modeArgs ?? null, headCommit ?? null]
|
|
91
|
+
);
|
|
92
|
+
return result.rows[0];
|
|
93
|
+
}
|
|
94
|
+
async function getReview(id) {
|
|
95
|
+
const db2 = await getDb();
|
|
96
|
+
const result = await db2.query("SELECT * FROM reviews WHERE id = $1", [id]);
|
|
97
|
+
return result.rows[0];
|
|
98
|
+
}
|
|
99
|
+
async function listReviews(repoPath) {
|
|
100
|
+
const db2 = await getDb();
|
|
101
|
+
if (repoPath) {
|
|
102
|
+
const result2 = await db2.query(
|
|
103
|
+
"SELECT * FROM reviews WHERE repo_path = $1 ORDER BY created_at DESC",
|
|
104
|
+
[repoPath]
|
|
105
|
+
);
|
|
106
|
+
return result2.rows;
|
|
107
|
+
}
|
|
108
|
+
const result = await db2.query("SELECT * FROM reviews ORDER BY created_at DESC");
|
|
109
|
+
return result.rows;
|
|
110
|
+
}
|
|
111
|
+
async function updateReviewStatus(id, status) {
|
|
112
|
+
const db2 = await getDb();
|
|
113
|
+
await db2.query("UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2", [status, id]);
|
|
114
|
+
}
|
|
115
|
+
async function updateReviewHead(id, headCommit) {
|
|
116
|
+
const db2 = await getDb();
|
|
117
|
+
await db2.query("UPDATE reviews SET head_commit = $1, updated_at = NOW() WHERE id = $2", [headCommit, id]);
|
|
118
|
+
}
|
|
119
|
+
async function deleteReview(id) {
|
|
120
|
+
const db2 = await getDb();
|
|
121
|
+
await db2.query("DELETE FROM annotations WHERE review_file_id IN (SELECT id FROM review_files WHERE review_id = $1)", [id]);
|
|
122
|
+
await db2.query("DELETE FROM review_files WHERE review_id = $1", [id]);
|
|
123
|
+
await db2.query("DELETE FROM reviews WHERE id = $1", [id]);
|
|
124
|
+
}
|
|
125
|
+
async function getLatestInProgressReview(repoPath, mode, modeArgs) {
|
|
126
|
+
const db2 = await getDb();
|
|
127
|
+
const result = await db2.query(
|
|
128
|
+
`SELECT * FROM reviews
|
|
129
|
+
WHERE repo_path = $1 AND mode = $2 AND status = 'in_progress'
|
|
130
|
+
AND ($3::text IS NULL OR mode_args = $3)
|
|
131
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
132
|
+
[repoPath, mode, modeArgs ?? null]
|
|
133
|
+
);
|
|
134
|
+
return result.rows[0];
|
|
135
|
+
}
|
|
136
|
+
async function addReviewFile(reviewId, filePath, diffData) {
|
|
137
|
+
const db2 = await getDb();
|
|
138
|
+
const id = generateId();
|
|
139
|
+
const result = await db2.query(
|
|
140
|
+
`INSERT INTO review_files (id, review_id, file_path, diff_data)
|
|
141
|
+
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
142
|
+
[id, reviewId, filePath, diffData]
|
|
143
|
+
);
|
|
144
|
+
return result.rows[0];
|
|
145
|
+
}
|
|
146
|
+
async function getReviewFiles(reviewId) {
|
|
147
|
+
const db2 = await getDb();
|
|
148
|
+
const result = await db2.query(
|
|
149
|
+
"SELECT * FROM review_files WHERE review_id = $1 ORDER BY file_path",
|
|
150
|
+
[reviewId]
|
|
151
|
+
);
|
|
152
|
+
return result.rows;
|
|
153
|
+
}
|
|
154
|
+
async function getReviewFile(id) {
|
|
155
|
+
const db2 = await getDb();
|
|
156
|
+
const result = await db2.query("SELECT * FROM review_files WHERE id = $1", [id]);
|
|
157
|
+
return result.rows[0];
|
|
158
|
+
}
|
|
159
|
+
async function updateFileStatus(id, status) {
|
|
160
|
+
const db2 = await getDb();
|
|
161
|
+
await db2.query("UPDATE review_files SET status = $1 WHERE id = $2", [status, id]);
|
|
162
|
+
}
|
|
163
|
+
async function updateFileDiff(id, diffData) {
|
|
164
|
+
const db2 = await getDb();
|
|
165
|
+
await db2.query("UPDATE review_files SET diff_data = $1 WHERE id = $2", [diffData, id]);
|
|
166
|
+
}
|
|
167
|
+
async function deleteReviewFile(id) {
|
|
168
|
+
const db2 = await getDb();
|
|
169
|
+
await db2.query("DELETE FROM annotations WHERE review_file_id = $1", [id]);
|
|
170
|
+
await db2.query("DELETE FROM review_files WHERE id = $1", [id]);
|
|
171
|
+
}
|
|
172
|
+
async function addAnnotation(reviewFileId, lineNumber, side, category, content) {
|
|
173
|
+
const db2 = await getDb();
|
|
174
|
+
const id = generateId();
|
|
175
|
+
const result = await db2.query(
|
|
176
|
+
`INSERT INTO annotations (id, review_file_id, line_number, side, category, content)
|
|
177
|
+
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
|
178
|
+
[id, reviewFileId, lineNumber, side, category, content]
|
|
179
|
+
);
|
|
180
|
+
return result.rows[0];
|
|
181
|
+
}
|
|
182
|
+
async function getAnnotationsForFile(reviewFileId) {
|
|
183
|
+
const db2 = await getDb();
|
|
184
|
+
const result = await db2.query(
|
|
185
|
+
"SELECT * FROM annotations WHERE review_file_id = $1 ORDER BY line_number, created_at",
|
|
186
|
+
[reviewFileId]
|
|
187
|
+
);
|
|
188
|
+
return result.rows;
|
|
189
|
+
}
|
|
190
|
+
async function getAnnotationsForReview(reviewId) {
|
|
191
|
+
const db2 = await getDb();
|
|
192
|
+
const result = await db2.query(
|
|
193
|
+
`SELECT a.*, rf.file_path FROM annotations a
|
|
194
|
+
JOIN review_files rf ON a.review_file_id = rf.id
|
|
195
|
+
WHERE rf.review_id = $1
|
|
196
|
+
ORDER BY rf.file_path, a.line_number, a.created_at`,
|
|
197
|
+
[reviewId]
|
|
198
|
+
);
|
|
199
|
+
return result.rows;
|
|
200
|
+
}
|
|
201
|
+
async function updateAnnotation(id, content, category) {
|
|
202
|
+
const db2 = await getDb();
|
|
203
|
+
await db2.query(
|
|
204
|
+
"UPDATE annotations SET content = $1, category = $2, updated_at = NOW() WHERE id = $3",
|
|
205
|
+
[content, category, id]
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
async function deleteAnnotation(id) {
|
|
209
|
+
const db2 = await getDb();
|
|
210
|
+
await db2.query("DELETE FROM annotations WHERE id = $1", [id]);
|
|
211
|
+
}
|
|
212
|
+
async function moveAnnotation(id, lineNumber, side) {
|
|
213
|
+
const db2 = await getDb();
|
|
214
|
+
await db2.query(
|
|
215
|
+
"UPDATE annotations SET line_number = $1, side = $2, is_stale = FALSE, original_content = NULL, updated_at = NOW() WHERE id = $3",
|
|
216
|
+
[lineNumber, side, id]
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
async function markAnnotationStale(id, originalContent) {
|
|
220
|
+
const db2 = await getDb();
|
|
221
|
+
await db2.query(
|
|
222
|
+
"UPDATE annotations SET is_stale = TRUE, original_content = $1, updated_at = NOW() WHERE id = $2",
|
|
223
|
+
[originalContent, id]
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
async function markAnnotationCurrent(id) {
|
|
227
|
+
const db2 = await getDb();
|
|
228
|
+
await db2.query(
|
|
229
|
+
"UPDATE annotations SET is_stale = FALSE, original_content = NULL, updated_at = NOW() WHERE id = $1",
|
|
230
|
+
[id]
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
async function deleteStaleAnnotations(reviewId) {
|
|
234
|
+
const db2 = await getDb();
|
|
235
|
+
await db2.query(
|
|
236
|
+
`DELETE FROM annotations WHERE is_stale = TRUE AND review_file_id IN
|
|
237
|
+
(SELECT id FROM review_files WHERE review_id = $1)`,
|
|
238
|
+
[reviewId]
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
async function keepAllStaleAnnotations(reviewId) {
|
|
242
|
+
const db2 = await getDb();
|
|
243
|
+
await db2.query(
|
|
244
|
+
`UPDATE annotations SET is_stale = FALSE, original_content = NULL, updated_at = NOW()
|
|
245
|
+
WHERE is_stale = TRUE AND review_file_id IN
|
|
246
|
+
(SELECT id FROM review_files WHERE review_id = $1)`,
|
|
247
|
+
[reviewId]
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
async function getStaleCountsForReview(reviewId) {
|
|
251
|
+
const db2 = await getDb();
|
|
252
|
+
const result = await db2.query(
|
|
253
|
+
`SELECT a.review_file_id, COUNT(*)::text as count FROM annotations a
|
|
254
|
+
JOIN review_files rf ON a.review_file_id = rf.id
|
|
255
|
+
WHERE rf.review_id = $1 AND a.is_stale = TRUE
|
|
256
|
+
GROUP BY a.review_file_id`,
|
|
257
|
+
[reviewId]
|
|
258
|
+
);
|
|
259
|
+
const counts = {};
|
|
260
|
+
for (const row of result.rows) {
|
|
261
|
+
counts[row.review_file_id] = parseInt(row.count, 10);
|
|
262
|
+
}
|
|
263
|
+
return counts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/export/generate.ts
|
|
267
|
+
import { mkdirSync as mkdirSync2, writeFileSync, readFileSync, unlinkSync, existsSync, appendFileSync } from "fs";
|
|
268
|
+
import { join as join2 } from "path";
|
|
269
|
+
import { homedir as homedir2 } from "os";
|
|
270
|
+
import { execSync } from "child_process";
|
|
271
|
+
var DISMISS_FILE = join2(homedir2(), ".glassbox", "gitignore-dismissed.json");
|
|
272
|
+
var DISMISS_DAYS = 30;
|
|
273
|
+
function loadDismissals() {
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(readFileSync(DISMISS_FILE, "utf-8"));
|
|
276
|
+
} catch {
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function saveDismissals(data) {
|
|
281
|
+
const dir = join2(homedir2(), ".glassbox");
|
|
282
|
+
mkdirSync2(dir, { recursive: true });
|
|
283
|
+
writeFileSync(DISMISS_FILE, JSON.stringify(data), "utf-8");
|
|
284
|
+
}
|
|
285
|
+
function isGlassboxGitignored(repoRoot) {
|
|
286
|
+
try {
|
|
287
|
+
execSync("git check-ignore -q .glassbox", { cwd: repoRoot, stdio: "pipe" });
|
|
288
|
+
return true;
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function shouldPromptGitignore(repoRoot) {
|
|
294
|
+
if (isGlassboxGitignored(repoRoot)) return false;
|
|
295
|
+
const dismissals = loadDismissals();
|
|
296
|
+
const dismissed = dismissals[repoRoot];
|
|
297
|
+
if (dismissed) {
|
|
298
|
+
const daysSince = (Date.now() - dismissed) / (1e3 * 60 * 60 * 24);
|
|
299
|
+
if (daysSince < DISMISS_DAYS) return false;
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
function addGlassboxToGitignore(repoRoot) {
|
|
304
|
+
const gitignorePath = join2(repoRoot, ".gitignore");
|
|
305
|
+
if (existsSync(gitignorePath)) {
|
|
306
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
307
|
+
if (!content.endsWith("\n")) {
|
|
308
|
+
appendFileSync(gitignorePath, "\n.glassbox/\n", "utf-8");
|
|
309
|
+
} else {
|
|
310
|
+
appendFileSync(gitignorePath, ".glassbox/\n", "utf-8");
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
writeFileSync(gitignorePath, ".glassbox/\n", "utf-8");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function dismissGitignorePrompt(repoRoot) {
|
|
317
|
+
const dismissals = loadDismissals();
|
|
318
|
+
dismissals[repoRoot] = Date.now();
|
|
319
|
+
saveDismissals(dismissals);
|
|
320
|
+
}
|
|
321
|
+
function deleteReviewExport(reviewId, repoRoot) {
|
|
322
|
+
const exportDir = join2(repoRoot, ".glassbox");
|
|
323
|
+
const archivePath = join2(exportDir, `review-${reviewId}.md`);
|
|
324
|
+
if (existsSync(archivePath)) unlinkSync(archivePath);
|
|
325
|
+
}
|
|
326
|
+
async function generateReviewExport(reviewId, repoRoot, isCurrent) {
|
|
327
|
+
const review = await getReview(reviewId);
|
|
328
|
+
if (!review) throw new Error("Review not found");
|
|
329
|
+
const files = await getReviewFiles(reviewId);
|
|
330
|
+
const annotations = await getAnnotationsForReview(reviewId);
|
|
331
|
+
const exportDir = join2(repoRoot, ".glassbox");
|
|
332
|
+
mkdirSync2(exportDir, { recursive: true });
|
|
333
|
+
const byFile = {};
|
|
334
|
+
for (const a of annotations) {
|
|
335
|
+
if (!byFile[a.file_path]) byFile[a.file_path] = [];
|
|
336
|
+
byFile[a.file_path].push(a);
|
|
337
|
+
}
|
|
338
|
+
const lines = [];
|
|
339
|
+
lines.push("# Code Review");
|
|
340
|
+
lines.push("");
|
|
341
|
+
lines.push(`- **Repository**: ${review.repo_name}`);
|
|
342
|
+
lines.push(`- **Review mode**: ${review.mode}${review.mode_args ? ` (${review.mode_args})` : ""}`);
|
|
343
|
+
lines.push(`- **Review ID**: ${review.id}`);
|
|
344
|
+
lines.push(`- **Date**: ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
345
|
+
lines.push(`- **Files reviewed**: ${files.filter((f) => f.status === "reviewed").length}/${files.length}`);
|
|
346
|
+
lines.push(`- **Total annotations**: ${annotations.length}`);
|
|
347
|
+
lines.push("");
|
|
348
|
+
const categoryCounts = {};
|
|
349
|
+
for (const a of annotations) {
|
|
350
|
+
categoryCounts[a.category] = (categoryCounts[a.category] || 0) + 1;
|
|
351
|
+
}
|
|
352
|
+
if (Object.keys(categoryCounts).length > 0) {
|
|
353
|
+
lines.push("## Annotation Summary");
|
|
354
|
+
lines.push("");
|
|
355
|
+
for (const [cat, count] of Object.entries(categoryCounts).sort((a, b) => b[1] - a[1])) {
|
|
356
|
+
lines.push(`- **${cat}**: ${count}`);
|
|
357
|
+
}
|
|
358
|
+
lines.push("");
|
|
359
|
+
}
|
|
360
|
+
const rememberItems = annotations.filter((a) => a.category === "remember");
|
|
361
|
+
if (rememberItems.length > 0) {
|
|
362
|
+
lines.push("## Items to Remember");
|
|
363
|
+
lines.push("");
|
|
364
|
+
lines.push("> These annotations are flagged for long-term retention. AI tools should consider updating");
|
|
365
|
+
lines.push("> project configuration (CLAUDE.md, .cursorrules, etc.) with these preferences/rules.");
|
|
366
|
+
lines.push("");
|
|
367
|
+
for (const item of rememberItems) {
|
|
368
|
+
lines.push(`- **${item.file_path}:${item.line_number}** - ${item.content}`);
|
|
369
|
+
}
|
|
370
|
+
lines.push("");
|
|
371
|
+
}
|
|
372
|
+
lines.push("## File Annotations");
|
|
373
|
+
lines.push("");
|
|
374
|
+
for (const filePath of Object.keys(byFile).sort()) {
|
|
375
|
+
const fileAnns = byFile[filePath];
|
|
376
|
+
lines.push(`### ${filePath}`);
|
|
377
|
+
lines.push("");
|
|
378
|
+
for (const a of fileAnns) {
|
|
379
|
+
lines.push(`- **Line ${a.line_number}** [${a.category}]: ${a.content}`);
|
|
380
|
+
}
|
|
381
|
+
lines.push("");
|
|
382
|
+
}
|
|
383
|
+
lines.push("---");
|
|
384
|
+
lines.push("");
|
|
385
|
+
lines.push("## Instructions for AI Tools");
|
|
386
|
+
lines.push("");
|
|
387
|
+
lines.push("When processing this code review:");
|
|
388
|
+
lines.push("");
|
|
389
|
+
lines.push("1. **bug** and **fix** annotations indicate code that needs to be changed. Apply the suggested fixes.");
|
|
390
|
+
lines.push("2. **style** annotations indicate stylistic preferences. Apply them to the indicated lines and similar patterns nearby.");
|
|
391
|
+
lines.push("3. **pattern-follow** annotations highlight good patterns. Continue using these patterns in new code.");
|
|
392
|
+
lines.push("4. **pattern-avoid** annotations highlight anti-patterns. Refactor the indicated code and avoid the pattern elsewhere.");
|
|
393
|
+
lines.push("5. **remember** annotations are rules/preferences to persist. Update the project's AI configuration file (e.g., CLAUDE.md) with these.");
|
|
394
|
+
lines.push("6. **note** annotations are informational context. Consider them but they may not require code changes.");
|
|
395
|
+
lines.push("");
|
|
396
|
+
const content = lines.join("\n");
|
|
397
|
+
const archivePath = join2(exportDir, `review-${review.id}.md`);
|
|
398
|
+
writeFileSync(archivePath, content, "utf-8");
|
|
399
|
+
if (isCurrent) {
|
|
400
|
+
const latestPath = join2(exportDir, "latest-review.md");
|
|
401
|
+
writeFileSync(latestPath, content, "utf-8");
|
|
402
|
+
return latestPath;
|
|
403
|
+
}
|
|
404
|
+
return archivePath;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/git/diff.ts
|
|
408
|
+
import { execSync as execSync2 } from "child_process";
|
|
409
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
410
|
+
import { resolve } from "path";
|
|
411
|
+
function git(args, cwd) {
|
|
412
|
+
try {
|
|
413
|
+
return execSync2(`git ${args}`, { cwd, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
|
|
414
|
+
} catch (e) {
|
|
415
|
+
const err = e;
|
|
416
|
+
if (err.stdout) return err.stdout;
|
|
417
|
+
throw e;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function getRepoRoot(cwd) {
|
|
421
|
+
return git("rev-parse --show-toplevel", cwd).trim();
|
|
422
|
+
}
|
|
423
|
+
function getRepoName(cwd) {
|
|
424
|
+
const root = getRepoRoot(cwd);
|
|
425
|
+
return root.split("/").pop() || "unknown";
|
|
426
|
+
}
|
|
427
|
+
function isGitRepo(cwd) {
|
|
428
|
+
try {
|
|
429
|
+
git("rev-parse --is-inside-work-tree", cwd);
|
|
430
|
+
return true;
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function getDiffArgs(mode) {
|
|
436
|
+
switch (mode.type) {
|
|
437
|
+
case "uncommitted":
|
|
438
|
+
return "diff HEAD";
|
|
439
|
+
case "staged":
|
|
440
|
+
return "diff --cached";
|
|
441
|
+
case "unstaged":
|
|
442
|
+
return "diff";
|
|
443
|
+
case "commit":
|
|
444
|
+
return `diff ${mode.sha}~1 ${mode.sha}`;
|
|
445
|
+
case "range":
|
|
446
|
+
return `diff ${mode.from} ${mode.to}`;
|
|
447
|
+
case "branch": {
|
|
448
|
+
return `diff ${mode.name}...HEAD`;
|
|
449
|
+
}
|
|
450
|
+
case "files":
|
|
451
|
+
return `diff HEAD -- ${mode.patterns.join(" ")}`;
|
|
452
|
+
case "all":
|
|
453
|
+
return "diff --no-index /dev/null .";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function getFileDiffs(mode, cwd) {
|
|
457
|
+
const repoRoot = getRepoRoot(cwd);
|
|
458
|
+
if (mode.type === "all") {
|
|
459
|
+
return getAllFiles(repoRoot);
|
|
460
|
+
}
|
|
461
|
+
const diffArgs = getDiffArgs(mode);
|
|
462
|
+
let rawDiff;
|
|
463
|
+
try {
|
|
464
|
+
rawDiff = git(`${diffArgs} -U3`, repoRoot);
|
|
465
|
+
} catch {
|
|
466
|
+
rawDiff = "";
|
|
467
|
+
}
|
|
468
|
+
const diffs = parseDiff(rawDiff);
|
|
469
|
+
if (mode.type === "uncommitted") {
|
|
470
|
+
const untracked = git("ls-files --others --exclude-standard", repoRoot).trim();
|
|
471
|
+
if (untracked) {
|
|
472
|
+
for (const file of untracked.split("\n").filter(Boolean)) {
|
|
473
|
+
if (!diffs.some((d) => d.filePath === file)) {
|
|
474
|
+
diffs.push(createNewFileDiff(file, repoRoot));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return diffs;
|
|
480
|
+
}
|
|
481
|
+
function getAllFiles(repoRoot) {
|
|
482
|
+
const files = git("ls-files", repoRoot).trim().split("\n").filter(Boolean);
|
|
483
|
+
return files.map((file) => createNewFileDiff(file, repoRoot));
|
|
484
|
+
}
|
|
485
|
+
function createNewFileDiff(filePath, repoRoot) {
|
|
486
|
+
let content;
|
|
487
|
+
try {
|
|
488
|
+
const buf = readFileSync2(resolve(repoRoot, filePath));
|
|
489
|
+
const checkLen = Math.min(buf.length, 8192);
|
|
490
|
+
for (let i = 0; i < checkLen; i++) {
|
|
491
|
+
if (buf[i] === 0) {
|
|
492
|
+
return { filePath, oldPath: null, status: "added", hunks: [], isBinary: true };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
content = buf.toString("utf-8");
|
|
496
|
+
} catch {
|
|
497
|
+
content = "";
|
|
498
|
+
}
|
|
499
|
+
const lines = content.split("\n");
|
|
500
|
+
const diffLines = lines.map((line, i) => ({
|
|
501
|
+
type: "add",
|
|
502
|
+
oldNum: null,
|
|
503
|
+
newNum: i + 1,
|
|
504
|
+
content: line
|
|
505
|
+
}));
|
|
506
|
+
return {
|
|
507
|
+
filePath,
|
|
508
|
+
oldPath: null,
|
|
509
|
+
status: "added",
|
|
510
|
+
hunks: diffLines.length ? [{
|
|
511
|
+
oldStart: 0,
|
|
512
|
+
oldCount: 0,
|
|
513
|
+
newStart: 1,
|
|
514
|
+
newCount: lines.length,
|
|
515
|
+
lines: diffLines
|
|
516
|
+
}] : [],
|
|
517
|
+
isBinary: false
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function parseDiff(raw2) {
|
|
521
|
+
const files = [];
|
|
522
|
+
const fileChunks = raw2.split(/^diff --git /m).filter(Boolean);
|
|
523
|
+
for (const chunk of fileChunks) {
|
|
524
|
+
const headerEnd = chunk.indexOf("@@");
|
|
525
|
+
const header = headerEnd === -1 ? chunk : chunk.slice(0, headerEnd);
|
|
526
|
+
if (headerEnd === -1 && !header.includes("Binary")) {
|
|
527
|
+
const pathMatch2 = chunk.match(/^a\/(.+?) b\/(.+)/m);
|
|
528
|
+
if (pathMatch2) {
|
|
529
|
+
const isBinary2 = header.includes("Binary");
|
|
530
|
+
files.push({
|
|
531
|
+
filePath: pathMatch2[2],
|
|
532
|
+
oldPath: pathMatch2[1] !== pathMatch2[2] ? pathMatch2[1] : null,
|
|
533
|
+
status: header.includes("new file") ? "added" : header.includes("deleted file") ? "deleted" : "modified",
|
|
534
|
+
hunks: [],
|
|
535
|
+
isBinary: isBinary2
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const pathMatch = chunk.match(/^a\/(.+?) b\/(.+)/m);
|
|
541
|
+
if (!pathMatch) continue;
|
|
542
|
+
const filePath = pathMatch[2];
|
|
543
|
+
const oldPath = pathMatch[1] !== pathMatch[2] ? pathMatch[1] : null;
|
|
544
|
+
let status = "modified";
|
|
545
|
+
if (header.includes("new file mode")) status = "added";
|
|
546
|
+
else if (header.includes("deleted file mode")) status = "deleted";
|
|
547
|
+
else if (oldPath) status = "renamed";
|
|
548
|
+
const isBinary = header.includes("Binary file");
|
|
549
|
+
if (isBinary) {
|
|
550
|
+
files.push({ filePath, oldPath, status, hunks: [], isBinary: true });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const hunks = parseHunks(chunk.slice(headerEnd));
|
|
554
|
+
files.push({ filePath, oldPath, status, hunks, isBinary: false });
|
|
555
|
+
}
|
|
556
|
+
return files;
|
|
557
|
+
}
|
|
558
|
+
function parseHunks(raw2) {
|
|
559
|
+
const hunks = [];
|
|
560
|
+
const hunkRegex = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)/gm;
|
|
561
|
+
let match;
|
|
562
|
+
const hunkStarts = [];
|
|
563
|
+
while ((match = hunkRegex.exec(raw2)) !== null) {
|
|
564
|
+
hunkStarts.push({
|
|
565
|
+
index: match.index + match[0].length,
|
|
566
|
+
oldStart: parseInt(match[1], 10),
|
|
567
|
+
oldCount: match[2] != null ? parseInt(match[2], 10) : 1,
|
|
568
|
+
newStart: parseInt(match[3], 10),
|
|
569
|
+
newCount: match[4] != null ? parseInt(match[4], 10) : 1
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
for (let i = 0; i < hunkStarts.length; i++) {
|
|
573
|
+
const start = hunkStarts[i];
|
|
574
|
+
const end = i + 1 < hunkStarts.length ? raw2.lastIndexOf("\n@@", hunkStarts[i + 1].index) : raw2.length;
|
|
575
|
+
const body = raw2.slice(start.index, end);
|
|
576
|
+
const lines = [];
|
|
577
|
+
let oldNum = start.oldStart;
|
|
578
|
+
let newNum = start.newStart;
|
|
579
|
+
for (const line of body.split("\n")) {
|
|
580
|
+
if (line === "") continue;
|
|
581
|
+
if (line.startsWith("+")) {
|
|
582
|
+
lines.push({ type: "add", oldNum: null, newNum, content: line.slice(1) });
|
|
583
|
+
newNum++;
|
|
584
|
+
} else if (line.startsWith("-")) {
|
|
585
|
+
lines.push({ type: "remove", oldNum, newNum: null, content: line.slice(1) });
|
|
586
|
+
oldNum++;
|
|
587
|
+
} else if (line.startsWith(" ") || line.startsWith("\\")) {
|
|
588
|
+
if (line.startsWith("\\")) continue;
|
|
589
|
+
lines.push({ type: "context", oldNum, newNum, content: line.slice(1) });
|
|
590
|
+
oldNum++;
|
|
591
|
+
newNum++;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
hunks.push({
|
|
595
|
+
oldStart: start.oldStart,
|
|
596
|
+
oldCount: start.oldCount,
|
|
597
|
+
newStart: start.newStart,
|
|
598
|
+
newCount: start.newCount,
|
|
599
|
+
lines
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return hunks;
|
|
603
|
+
}
|
|
604
|
+
function getFileContent(filePath, ref, cwd) {
|
|
605
|
+
const repoRoot = getRepoRoot(cwd);
|
|
606
|
+
try {
|
|
607
|
+
if (ref === "working") {
|
|
608
|
+
return execSync2(`cat "${resolve(repoRoot, filePath)}"`, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
|
609
|
+
}
|
|
610
|
+
return git(`show ${ref}:${filePath}`, repoRoot);
|
|
611
|
+
} catch {
|
|
612
|
+
return "";
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function getHeadCommit(cwd) {
|
|
616
|
+
return execSync2("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
|
|
617
|
+
}
|
|
618
|
+
function getModeString(mode) {
|
|
619
|
+
switch (mode.type) {
|
|
620
|
+
case "uncommitted":
|
|
621
|
+
return "uncommitted";
|
|
622
|
+
case "staged":
|
|
623
|
+
return "staged";
|
|
624
|
+
case "unstaged":
|
|
625
|
+
return "unstaged";
|
|
626
|
+
case "commit":
|
|
627
|
+
return `commit:${mode.sha}`;
|
|
628
|
+
case "range":
|
|
629
|
+
return `range:${mode.from}..${mode.to}`;
|
|
630
|
+
case "branch":
|
|
631
|
+
return `branch:${mode.name}`;
|
|
632
|
+
case "files":
|
|
633
|
+
return `files:${mode.patterns.join(",")}`;
|
|
634
|
+
case "all":
|
|
635
|
+
return "all";
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function getModeArgs(mode) {
|
|
639
|
+
switch (mode.type) {
|
|
640
|
+
case "commit":
|
|
641
|
+
return mode.sha;
|
|
642
|
+
case "range":
|
|
643
|
+
return `${mode.from}..${mode.to}`;
|
|
644
|
+
case "branch":
|
|
645
|
+
return mode.name;
|
|
646
|
+
case "files":
|
|
647
|
+
return mode.patterns.join(",");
|
|
648
|
+
default:
|
|
649
|
+
return void 0;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/routes/api.ts
|
|
654
|
+
var apiRoutes = new Hono();
|
|
655
|
+
function resolveReviewId(c) {
|
|
656
|
+
return c.req.query("reviewId") || c.get("reviewId");
|
|
657
|
+
}
|
|
658
|
+
apiRoutes.get("/reviews", async (c) => {
|
|
659
|
+
const repoRoot = c.get("repoRoot");
|
|
660
|
+
const reviews = await listReviews(repoRoot);
|
|
661
|
+
return c.json(reviews);
|
|
662
|
+
});
|
|
663
|
+
apiRoutes.get("/review", async (c) => {
|
|
664
|
+
const reviewId = resolveReviewId(c);
|
|
665
|
+
const review = await getReview(reviewId);
|
|
666
|
+
return c.json(review);
|
|
667
|
+
});
|
|
668
|
+
apiRoutes.post("/review/complete", async (c) => {
|
|
669
|
+
const reviewId = resolveReviewId(c);
|
|
670
|
+
const currentReviewId = c.get("currentReviewId");
|
|
671
|
+
const repoRoot = c.get("repoRoot");
|
|
672
|
+
await updateReviewStatus(reviewId, "completed");
|
|
673
|
+
const isCurrent = reviewId === currentReviewId;
|
|
674
|
+
const exportPath = await generateReviewExport(reviewId, repoRoot, isCurrent);
|
|
675
|
+
const gitignorePrompt = shouldPromptGitignore(repoRoot);
|
|
676
|
+
return c.json({ status: "completed", exportPath, isCurrent, reviewId, gitignorePrompt });
|
|
677
|
+
});
|
|
678
|
+
apiRoutes.post("/gitignore/add", async (c) => {
|
|
679
|
+
const repoRoot = c.get("repoRoot");
|
|
680
|
+
addGlassboxToGitignore(repoRoot);
|
|
681
|
+
return c.json({ ok: true });
|
|
682
|
+
});
|
|
683
|
+
apiRoutes.post("/gitignore/dismiss", async (c) => {
|
|
684
|
+
const repoRoot = c.get("repoRoot");
|
|
685
|
+
dismissGitignorePrompt(repoRoot);
|
|
686
|
+
return c.json({ ok: true });
|
|
687
|
+
});
|
|
688
|
+
apiRoutes.post("/review/reopen", async (c) => {
|
|
689
|
+
const reviewId = resolveReviewId(c);
|
|
690
|
+
await updateReviewStatus(reviewId, "in_progress");
|
|
691
|
+
return c.json({ status: "in_progress" });
|
|
692
|
+
});
|
|
693
|
+
apiRoutes.delete("/review/:id", async (c) => {
|
|
694
|
+
const reviewId = c.req.param("id");
|
|
695
|
+
const currentReviewId = c.get("currentReviewId");
|
|
696
|
+
if (reviewId === currentReviewId) {
|
|
697
|
+
return c.json({ error: "Cannot delete the current review" }, 400);
|
|
698
|
+
}
|
|
699
|
+
const repoRoot = c.get("repoRoot");
|
|
700
|
+
deleteReviewExport(reviewId, repoRoot);
|
|
701
|
+
await deleteReview(reviewId);
|
|
702
|
+
return c.json({ ok: true });
|
|
703
|
+
});
|
|
704
|
+
apiRoutes.post("/reviews/delete-completed", async (c) => {
|
|
705
|
+
const currentReviewId = c.get("currentReviewId");
|
|
706
|
+
const repoRoot = c.get("repoRoot");
|
|
707
|
+
const reviews = await listReviews(repoRoot);
|
|
708
|
+
const toDelete = reviews.filter((r) => r.status === "completed" && r.id !== currentReviewId);
|
|
709
|
+
for (const r of toDelete) {
|
|
710
|
+
deleteReviewExport(r.id, repoRoot);
|
|
711
|
+
await deleteReview(r.id);
|
|
712
|
+
}
|
|
713
|
+
return c.json({ deleted: toDelete.length });
|
|
714
|
+
});
|
|
715
|
+
apiRoutes.post("/reviews/delete-all", async (c) => {
|
|
716
|
+
const currentReviewId = c.get("currentReviewId");
|
|
717
|
+
const repoRoot = c.get("repoRoot");
|
|
718
|
+
const reviews = await listReviews(repoRoot);
|
|
719
|
+
const toDelete = reviews.filter((r) => r.id !== currentReviewId);
|
|
720
|
+
for (const r of toDelete) {
|
|
721
|
+
deleteReviewExport(r.id, repoRoot);
|
|
722
|
+
await deleteReview(r.id);
|
|
723
|
+
}
|
|
724
|
+
return c.json({ deleted: toDelete.length });
|
|
725
|
+
});
|
|
726
|
+
apiRoutes.get("/files", async (c) => {
|
|
727
|
+
const reviewId = resolveReviewId(c);
|
|
728
|
+
const files = await getReviewFiles(reviewId);
|
|
729
|
+
const annotationCounts = {};
|
|
730
|
+
for (const file of files) {
|
|
731
|
+
const annotations = await getAnnotationsForFile(file.id);
|
|
732
|
+
annotationCounts[file.id] = annotations.length;
|
|
733
|
+
}
|
|
734
|
+
const staleCounts = await getStaleCountsForReview(reviewId);
|
|
735
|
+
return c.json({ files, annotationCounts, staleCounts });
|
|
736
|
+
});
|
|
737
|
+
apiRoutes.get("/files/:fileId", async (c) => {
|
|
738
|
+
const file = await getReviewFile(c.req.param("fileId"));
|
|
739
|
+
if (!file) return c.json({ error: "Not found" }, 404);
|
|
740
|
+
const annotations = await getAnnotationsForFile(file.id);
|
|
741
|
+
return c.json({ file, annotations });
|
|
742
|
+
});
|
|
743
|
+
apiRoutes.patch("/files/:fileId/status", async (c) => {
|
|
744
|
+
const { status } = await c.req.json();
|
|
745
|
+
await updateFileStatus(c.req.param("fileId"), status);
|
|
746
|
+
return c.json({ ok: true });
|
|
747
|
+
});
|
|
748
|
+
apiRoutes.post("/annotations", async (c) => {
|
|
749
|
+
const body = await c.req.json();
|
|
750
|
+
const annotation = await addAnnotation(
|
|
751
|
+
body.reviewFileId,
|
|
752
|
+
body.lineNumber,
|
|
753
|
+
body.side,
|
|
754
|
+
body.category,
|
|
755
|
+
body.content
|
|
756
|
+
);
|
|
757
|
+
return c.json(annotation, 201);
|
|
758
|
+
});
|
|
759
|
+
apiRoutes.patch("/annotations/:id", async (c) => {
|
|
760
|
+
const { content, category } = await c.req.json();
|
|
761
|
+
await updateAnnotation(c.req.param("id"), content, category);
|
|
762
|
+
return c.json({ ok: true });
|
|
763
|
+
});
|
|
764
|
+
apiRoutes.delete("/annotations/:id", async (c) => {
|
|
765
|
+
await deleteAnnotation(c.req.param("id"));
|
|
766
|
+
return c.json({ ok: true });
|
|
767
|
+
});
|
|
768
|
+
apiRoutes.patch("/annotations/:id/move", async (c) => {
|
|
769
|
+
const { lineNumber, side } = await c.req.json();
|
|
770
|
+
await moveAnnotation(c.req.param("id"), lineNumber, side);
|
|
771
|
+
return c.json({ ok: true });
|
|
772
|
+
});
|
|
773
|
+
apiRoutes.post("/annotations/:id/keep", async (c) => {
|
|
774
|
+
await markAnnotationCurrent(c.req.param("id"));
|
|
775
|
+
return c.json({ ok: true });
|
|
776
|
+
});
|
|
777
|
+
apiRoutes.post("/annotations/stale/delete-all", async (c) => {
|
|
778
|
+
const reviewId = resolveReviewId(c);
|
|
779
|
+
await deleteStaleAnnotations(reviewId);
|
|
780
|
+
return c.json({ ok: true });
|
|
781
|
+
});
|
|
782
|
+
apiRoutes.post("/annotations/stale/keep-all", async (c) => {
|
|
783
|
+
const reviewId = resolveReviewId(c);
|
|
784
|
+
await keepAllStaleAnnotations(reviewId);
|
|
785
|
+
return c.json({ ok: true });
|
|
786
|
+
});
|
|
787
|
+
apiRoutes.get("/annotations/all", async (c) => {
|
|
788
|
+
const reviewId = resolveReviewId(c);
|
|
789
|
+
const annotations = await getAnnotationsForReview(reviewId);
|
|
790
|
+
return c.json(annotations);
|
|
791
|
+
});
|
|
792
|
+
apiRoutes.get("/context/:fileId", async (c) => {
|
|
793
|
+
const repoRoot = c.get("repoRoot");
|
|
794
|
+
const file = await getReviewFile(c.req.param("fileId"));
|
|
795
|
+
if (!file) return c.json({ error: "Not found" }, 404);
|
|
796
|
+
const startLine = parseInt(c.req.query("start") || "1", 10);
|
|
797
|
+
const endLine = parseInt(c.req.query("end") || "20", 10);
|
|
798
|
+
const content = getFileContent(file.file_path, "working", repoRoot);
|
|
799
|
+
const allLines = content.split("\n");
|
|
800
|
+
const clampedStart = Math.max(1, startLine);
|
|
801
|
+
const clampedEnd = Math.min(allLines.length, endLine);
|
|
802
|
+
const lines = [];
|
|
803
|
+
for (let i = clampedStart; i <= clampedEnd; i++) {
|
|
804
|
+
lines.push({ num: i, content: allLines[i - 1] || "" });
|
|
805
|
+
}
|
|
806
|
+
return c.json({ lines });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// src/routes/pages.tsx
|
|
810
|
+
import { Hono as Hono2 } from "hono";
|
|
811
|
+
|
|
812
|
+
// src/utils/escapeHtml.ts
|
|
813
|
+
function escapeHtml(str) {
|
|
814
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
815
|
+
}
|
|
816
|
+
function escapeAttr(str) {
|
|
817
|
+
return str.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/jsx-runtime.ts
|
|
821
|
+
var SafeHtml = class {
|
|
822
|
+
__html;
|
|
823
|
+
constructor(html) {
|
|
824
|
+
this.__html = html;
|
|
825
|
+
}
|
|
826
|
+
toString() {
|
|
827
|
+
return this.__html;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
function raw(html) {
|
|
831
|
+
return new SafeHtml(html);
|
|
832
|
+
}
|
|
833
|
+
var VOID_TAGS = /* @__PURE__ */ new Set([
|
|
834
|
+
"area",
|
|
835
|
+
"base",
|
|
836
|
+
"br",
|
|
837
|
+
"col",
|
|
838
|
+
"embed",
|
|
839
|
+
"hr",
|
|
840
|
+
"img",
|
|
841
|
+
"input",
|
|
842
|
+
"link",
|
|
843
|
+
"meta",
|
|
844
|
+
"source",
|
|
845
|
+
"track",
|
|
846
|
+
"wbr"
|
|
847
|
+
]);
|
|
848
|
+
function renderChildren(children) {
|
|
849
|
+
if (children == null || typeof children === "boolean") return "";
|
|
850
|
+
if (children instanceof SafeHtml) return children.__html;
|
|
851
|
+
if (typeof children === "string") return escapeHtml(children);
|
|
852
|
+
if (typeof children === "number") return String(children);
|
|
853
|
+
if (Array.isArray(children)) return children.map(renderChildren).join("");
|
|
854
|
+
return "";
|
|
855
|
+
}
|
|
856
|
+
function renderAttr(key, value) {
|
|
857
|
+
if (value == null || value === false) return "";
|
|
858
|
+
if (value === true) return ` ${key}`;
|
|
859
|
+
const name = key === "className" ? "class" : key === "htmlFor" ? "for" : key;
|
|
860
|
+
let strValue;
|
|
861
|
+
if (value instanceof SafeHtml) {
|
|
862
|
+
strValue = value.__html;
|
|
863
|
+
} else if (typeof value === "number") {
|
|
864
|
+
strValue = String(value);
|
|
865
|
+
} else if (typeof value === "string") {
|
|
866
|
+
strValue = escapeAttr(value);
|
|
867
|
+
} else {
|
|
868
|
+
strValue = "";
|
|
869
|
+
}
|
|
870
|
+
return ` ${name}="${strValue}"`;
|
|
871
|
+
}
|
|
872
|
+
function jsx(tag, props) {
|
|
873
|
+
if (typeof tag === "function") return tag(props);
|
|
874
|
+
const { children, ...attrs } = props;
|
|
875
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => renderAttr(k, v)).join("");
|
|
876
|
+
if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);
|
|
877
|
+
const childStr = children != null ? renderChildren(children) : "";
|
|
878
|
+
return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/components/layout.tsx
|
|
882
|
+
function Layout({ title, reviewId, children }) {
|
|
883
|
+
return /* @__PURE__ */ jsx("html", { lang: "en", children: [
|
|
884
|
+
/* @__PURE__ */ jsx("head", { children: [
|
|
885
|
+
/* @__PURE__ */ jsx("meta", { charset: "utf-8" }),
|
|
886
|
+
/* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
|
|
887
|
+
/* @__PURE__ */ jsx("title", { children: title }),
|
|
888
|
+
/* @__PURE__ */ jsx("style", { children: raw(getStyles()) })
|
|
889
|
+
] }),
|
|
890
|
+
/* @__PURE__ */ jsx("body", { "data-review-id": reviewId, children: [
|
|
891
|
+
children,
|
|
892
|
+
/* @__PURE__ */ jsx("script", { children: raw(getClientScript()) })
|
|
893
|
+
] })
|
|
894
|
+
] });
|
|
895
|
+
}
|
|
896
|
+
function getStyles() {
|
|
897
|
+
return `
|
|
898
|
+
:root {
|
|
899
|
+
--bg: #1e1e2e;
|
|
900
|
+
--bg-surface: #252536;
|
|
901
|
+
--bg-hover: #2d2d44;
|
|
902
|
+
--bg-active: #363652;
|
|
903
|
+
--text: #cdd6f4;
|
|
904
|
+
--text-dim: #8888aa;
|
|
905
|
+
--text-bright: #ffffff;
|
|
906
|
+
--accent: #89b4fa;
|
|
907
|
+
--accent-hover: #74a8fc;
|
|
908
|
+
--green: #a6e3a1;
|
|
909
|
+
--red: #f38ba8;
|
|
910
|
+
--yellow: #f9e2af;
|
|
911
|
+
--orange: #fab387;
|
|
912
|
+
--blue: #89b4fa;
|
|
913
|
+
--purple: #cba6f7;
|
|
914
|
+
--teal: #94e2d5;
|
|
915
|
+
--border: #363652;
|
|
916
|
+
--diff-add-bg: rgba(166, 227, 161, 0.1);
|
|
917
|
+
--diff-add-border: rgba(166, 227, 161, 0.3);
|
|
918
|
+
--diff-remove-bg: rgba(243, 139, 168, 0.1);
|
|
919
|
+
--diff-remove-border: rgba(243, 139, 168, 0.3);
|
|
920
|
+
--diff-context-bg: transparent;
|
|
921
|
+
--gutter-bg: #1a1a2e;
|
|
922
|
+
--gutter-text: #555577;
|
|
923
|
+
--sidebar-w: 300px;
|
|
924
|
+
--radius: 6px;
|
|
925
|
+
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
926
|
+
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
930
|
+
|
|
931
|
+
body {
|
|
932
|
+
font-family: var(--font-sans);
|
|
933
|
+
background: var(--bg);
|
|
934
|
+
color: var(--text);
|
|
935
|
+
height: 100vh;
|
|
936
|
+
overflow: hidden;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.review-app {
|
|
940
|
+
display: flex;
|
|
941
|
+
height: 100vh;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/* Sidebar */
|
|
945
|
+
.sidebar {
|
|
946
|
+
width: var(--sidebar-w);
|
|
947
|
+
min-width: 200px;
|
|
948
|
+
max-width: 60vw;
|
|
949
|
+
background: var(--bg-surface);
|
|
950
|
+
border-right: 1px solid var(--border);
|
|
951
|
+
display: flex;
|
|
952
|
+
flex-direction: column;
|
|
953
|
+
overflow: hidden;
|
|
954
|
+
flex-shrink: 0;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.sidebar-resize {
|
|
958
|
+
width: 4px;
|
|
959
|
+
cursor: col-resize;
|
|
960
|
+
background: transparent;
|
|
961
|
+
flex-shrink: 0;
|
|
962
|
+
transition: background 0.15s;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.sidebar-resize:hover,
|
|
966
|
+
.sidebar-resize.dragging {
|
|
967
|
+
background: var(--accent);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.sidebar-header {
|
|
971
|
+
padding: 16px;
|
|
972
|
+
border-bottom: 1px solid var(--border);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.sidebar-header h2 {
|
|
976
|
+
font-size: 16px;
|
|
977
|
+
font-weight: 600;
|
|
978
|
+
color: var(--text-bright);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.review-mode {
|
|
982
|
+
font-size: 12px;
|
|
983
|
+
color: var(--text-dim);
|
|
984
|
+
margin-top: 4px;
|
|
985
|
+
display: block;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
.sidebar-controls {
|
|
989
|
+
padding: 8px 16px;
|
|
990
|
+
border-bottom: 1px solid var(--border);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.diff-mode-toggle {
|
|
994
|
+
display: flex;
|
|
995
|
+
gap: 4px;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.file-filter {
|
|
999
|
+
padding: 8px 16px;
|
|
1000
|
+
border-bottom: 1px solid var(--border);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.file-filter-input {
|
|
1004
|
+
width: 100%;
|
|
1005
|
+
padding: 5px 8px;
|
|
1006
|
+
background: var(--bg);
|
|
1007
|
+
color: var(--text);
|
|
1008
|
+
border: 1px solid var(--border);
|
|
1009
|
+
border-radius: var(--radius);
|
|
1010
|
+
font-family: var(--font-mono);
|
|
1011
|
+
font-size: 12px;
|
|
1012
|
+
outline: none;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.file-filter-input:focus {
|
|
1016
|
+
border-color: var(--accent);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.file-filter-input::placeholder {
|
|
1020
|
+
color: var(--text-dim);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.sidebar-footer {
|
|
1024
|
+
padding: 16px;
|
|
1025
|
+
border-top: 1px solid var(--border);
|
|
1026
|
+
display: flex;
|
|
1027
|
+
flex-direction: column;
|
|
1028
|
+
gap: 8px;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/* File list */
|
|
1032
|
+
.file-list {
|
|
1033
|
+
flex: 1;
|
|
1034
|
+
overflow-y: auto;
|
|
1035
|
+
padding: 8px 0;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
.file-item {
|
|
1039
|
+
display: flex;
|
|
1040
|
+
align-items: center;
|
|
1041
|
+
padding: 6px 16px 6px 16px;
|
|
1042
|
+
cursor: pointer;
|
|
1043
|
+
font-size: 13px;
|
|
1044
|
+
gap: 8px;
|
|
1045
|
+
border-left: 3px solid transparent;
|
|
1046
|
+
transition: background 0.1s;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.file-item:hover { background: var(--bg-hover); }
|
|
1050
|
+
.file-item.active { background: var(--bg-active); border-left-color: var(--accent); }
|
|
1051
|
+
|
|
1052
|
+
.file-item .status-dot {
|
|
1053
|
+
width: 8px;
|
|
1054
|
+
height: 8px;
|
|
1055
|
+
border-radius: 50%;
|
|
1056
|
+
flex-shrink: 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.file-item .status-dot.pending { background: var(--text-dim); }
|
|
1060
|
+
.file-item .status-dot.reviewed { background: var(--green); }
|
|
1061
|
+
.file-item .status-dot.skipped { background: var(--yellow); }
|
|
1062
|
+
|
|
1063
|
+
.file-item .file-name {
|
|
1064
|
+
flex: 1;
|
|
1065
|
+
overflow: hidden;
|
|
1066
|
+
text-overflow: ellipsis;
|
|
1067
|
+
white-space: nowrap;
|
|
1068
|
+
font-family: var(--font-mono);
|
|
1069
|
+
font-size: 12px;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
.file-item .file-status {
|
|
1073
|
+
font-size: 11px;
|
|
1074
|
+
font-weight: 500;
|
|
1075
|
+
padding: 1px 6px;
|
|
1076
|
+
border-radius: 3px;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.file-status.added { color: var(--green); background: rgba(166,227,161,0.1); }
|
|
1080
|
+
.file-status.modified { color: var(--yellow); background: rgba(249,226,175,0.1); }
|
|
1081
|
+
.file-status.deleted { color: var(--red); background: rgba(243,139,168,0.1); }
|
|
1082
|
+
.file-status.renamed { color: var(--purple); background: rgba(203,166,247,0.1); }
|
|
1083
|
+
|
|
1084
|
+
.annotation-count {
|
|
1085
|
+
font-size: 11px;
|
|
1086
|
+
color: var(--accent);
|
|
1087
|
+
background: rgba(137,180,250,0.15);
|
|
1088
|
+
padding: 0 5px;
|
|
1089
|
+
border-radius: 8px;
|
|
1090
|
+
min-width: 18px;
|
|
1091
|
+
text-align: center;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/* Folder tree */
|
|
1095
|
+
.folder-header {
|
|
1096
|
+
display: flex;
|
|
1097
|
+
align-items: center;
|
|
1098
|
+
padding: 4px 16px;
|
|
1099
|
+
font-size: 12px;
|
|
1100
|
+
color: var(--text-dim);
|
|
1101
|
+
gap: 4px;
|
|
1102
|
+
user-select: none;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.folder-header.collapsible {
|
|
1106
|
+
cursor: pointer;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.folder-header.collapsible:hover {
|
|
1110
|
+
color: var(--text);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
.folder-header.collapsed + .folder-content {
|
|
1114
|
+
display: none;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
.folder-arrow {
|
|
1118
|
+
width: 12px;
|
|
1119
|
+
font-size: 10px;
|
|
1120
|
+
flex-shrink: 0;
|
|
1121
|
+
text-align: center;
|
|
1122
|
+
transition: transform 0.1s;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
.folder-header.collapsed .folder-arrow {
|
|
1126
|
+
transform: rotate(-90deg);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.folder-arrow-spacer {
|
|
1130
|
+
width: 12px;
|
|
1131
|
+
flex-shrink: 0;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
.folder-name {
|
|
1135
|
+
font-family: var(--font-mono);
|
|
1136
|
+
font-size: 12px;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/* Main content */
|
|
1140
|
+
.main-content {
|
|
1141
|
+
flex: 1;
|
|
1142
|
+
overflow: auto;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
.welcome-message {
|
|
1146
|
+
display: flex;
|
|
1147
|
+
flex-direction: column;
|
|
1148
|
+
align-items: center;
|
|
1149
|
+
justify-content: center;
|
|
1150
|
+
height: 100%;
|
|
1151
|
+
color: var(--text-dim);
|
|
1152
|
+
gap: 8px;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.welcome-message h3 { color: var(--text); }
|
|
1156
|
+
|
|
1157
|
+
/* Diff view */
|
|
1158
|
+
.diff-header {
|
|
1159
|
+
position: sticky;
|
|
1160
|
+
top: 0;
|
|
1161
|
+
z-index: 10;
|
|
1162
|
+
display: flex;
|
|
1163
|
+
align-items: center;
|
|
1164
|
+
justify-content: space-between;
|
|
1165
|
+
padding: 10px 16px;
|
|
1166
|
+
background: var(--bg-surface);
|
|
1167
|
+
border-bottom: 1px solid var(--border);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.diff-header .file-path {
|
|
1171
|
+
font-family: var(--font-mono);
|
|
1172
|
+
font-size: 13px;
|
|
1173
|
+
color: var(--text-bright);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.diff-header-actions {
|
|
1177
|
+
display: flex;
|
|
1178
|
+
gap: 8px;
|
|
1179
|
+
align-items: center;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/* Split diff */
|
|
1183
|
+
.diff-table-split {
|
|
1184
|
+
width: 100%;
|
|
1185
|
+
font-family: var(--font-mono);
|
|
1186
|
+
font-size: 13px;
|
|
1187
|
+
line-height: 20px;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
.split-row {
|
|
1191
|
+
display: grid;
|
|
1192
|
+
grid-template-columns: 1fr 1fr;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
.split-row .diff-line {
|
|
1196
|
+
display: flex;
|
|
1197
|
+
min-width: 0;
|
|
1198
|
+
overflow: hidden;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.split-row .diff-line.empty {
|
|
1202
|
+
background: var(--gutter-bg);
|
|
1203
|
+
min-height: 20px;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
.split-row .gutter {
|
|
1207
|
+
width: 50px;
|
|
1208
|
+
min-width: 50px;
|
|
1209
|
+
padding: 0 6px;
|
|
1210
|
+
text-align: right;
|
|
1211
|
+
color: var(--gutter-text);
|
|
1212
|
+
background: var(--gutter-bg);
|
|
1213
|
+
user-select: none;
|
|
1214
|
+
font-size: 12px;
|
|
1215
|
+
flex-shrink: 0;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.split-left { border-right: 1px solid var(--border); }
|
|
1219
|
+
|
|
1220
|
+
.diff-table-split .hunk-separator {
|
|
1221
|
+
grid-column: 1 / -1;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
.diff-line {
|
|
1225
|
+
display: flex;
|
|
1226
|
+
min-height: 20px;
|
|
1227
|
+
border-bottom: 1px solid rgba(54,54,82,0.3);
|
|
1228
|
+
cursor: pointer;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.diff-line:hover { filter: brightness(1.2); }
|
|
1232
|
+
|
|
1233
|
+
.diff-line.add { background: var(--diff-add-bg); }
|
|
1234
|
+
.diff-line.remove { background: var(--diff-remove-bg); }
|
|
1235
|
+
.diff-line.context { background: var(--diff-context-bg); }
|
|
1236
|
+
|
|
1237
|
+
.diff-line .gutter {
|
|
1238
|
+
width: 60px;
|
|
1239
|
+
min-width: 60px;
|
|
1240
|
+
padding: 0 8px;
|
|
1241
|
+
text-align: right;
|
|
1242
|
+
color: var(--gutter-text);
|
|
1243
|
+
background: var(--gutter-bg);
|
|
1244
|
+
user-select: none;
|
|
1245
|
+
font-size: 12px;
|
|
1246
|
+
display: flex;
|
|
1247
|
+
align-items: center;
|
|
1248
|
+
justify-content: flex-end;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
.diff-line .code {
|
|
1252
|
+
flex: 1;
|
|
1253
|
+
padding: 0 12px;
|
|
1254
|
+
white-space: pre;
|
|
1255
|
+
overflow-x: auto;
|
|
1256
|
+
tab-size: 4;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.diff-line.add .code::before { content: '+'; color: var(--green); margin-right: 4px; }
|
|
1260
|
+
.diff-line.remove .code::before { content: '-'; color: var(--red); margin-right: 4px; }
|
|
1261
|
+
|
|
1262
|
+
/* Unified diff */
|
|
1263
|
+
.diff-table-unified {
|
|
1264
|
+
width: 100%;
|
|
1265
|
+
font-family: var(--font-mono);
|
|
1266
|
+
font-size: 13px;
|
|
1267
|
+
line-height: 20px;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.diff-table-unified .diff-line {
|
|
1271
|
+
display: flex;
|
|
1272
|
+
min-width: 0;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.diff-table-unified .gutter-old,
|
|
1276
|
+
.diff-table-unified .gutter-new {
|
|
1277
|
+
width: 50px;
|
|
1278
|
+
min-width: 50px;
|
|
1279
|
+
padding: 0 6px;
|
|
1280
|
+
text-align: right;
|
|
1281
|
+
color: var(--gutter-text);
|
|
1282
|
+
background: var(--gutter-bg);
|
|
1283
|
+
user-select: none;
|
|
1284
|
+
font-size: 12px;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/* Hunk separator */
|
|
1288
|
+
.hunk-separator {
|
|
1289
|
+
padding: 4px 16px;
|
|
1290
|
+
background: rgba(137,180,250,0.05);
|
|
1291
|
+
color: var(--text-dim);
|
|
1292
|
+
font-size: 12px;
|
|
1293
|
+
font-family: var(--font-mono);
|
|
1294
|
+
border-top: 1px solid var(--border);
|
|
1295
|
+
border-bottom: 1px solid var(--border);
|
|
1296
|
+
cursor: pointer;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.hunk-separator:hover { background: rgba(137,180,250,0.1); }
|
|
1300
|
+
|
|
1301
|
+
.expand-controls {
|
|
1302
|
+
display: flex;
|
|
1303
|
+
gap: 8px;
|
|
1304
|
+
padding: 2px 16px;
|
|
1305
|
+
background: rgba(137,180,250,0.05);
|
|
1306
|
+
border-bottom: 1px solid var(--border);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
.expand-btn {
|
|
1310
|
+
font-size: 11px;
|
|
1311
|
+
color: var(--accent);
|
|
1312
|
+
background: none;
|
|
1313
|
+
border: none;
|
|
1314
|
+
cursor: pointer;
|
|
1315
|
+
padding: 2px 6px;
|
|
1316
|
+
border-radius: 3px;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
.expand-btn:hover { background: rgba(137,180,250,0.15); }
|
|
1320
|
+
|
|
1321
|
+
/* Annotation indicators */
|
|
1322
|
+
.has-annotation .gutter {
|
|
1323
|
+
position: relative;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
.has-annotation .gutter::after {
|
|
1327
|
+
content: '';
|
|
1328
|
+
position: absolute;
|
|
1329
|
+
right: 2px;
|
|
1330
|
+
top: 50%;
|
|
1331
|
+
transform: translateY(-50%);
|
|
1332
|
+
width: 6px;
|
|
1333
|
+
height: 6px;
|
|
1334
|
+
border-radius: 50%;
|
|
1335
|
+
background: var(--accent);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/* Annotation inline display */
|
|
1339
|
+
.annotation-row {
|
|
1340
|
+
padding: 8px 16px 8px 76px;
|
|
1341
|
+
background: rgba(137,180,250,0.05);
|
|
1342
|
+
border-left: 3px solid var(--accent);
|
|
1343
|
+
font-family: var(--font-sans);
|
|
1344
|
+
font-size: 13px;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.annotation-row .annotation-item {
|
|
1348
|
+
display: flex;
|
|
1349
|
+
gap: 8px;
|
|
1350
|
+
align-items: flex-start;
|
|
1351
|
+
padding: 4px 0;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.annotation-category {
|
|
1355
|
+
font-size: 11px;
|
|
1356
|
+
font-weight: 600;
|
|
1357
|
+
padding: 1px 6px;
|
|
1358
|
+
border-radius: 3px;
|
|
1359
|
+
white-space: nowrap;
|
|
1360
|
+
flex-shrink: 0;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.category-bug { color: var(--red); background: rgba(243,139,168,0.15); }
|
|
1364
|
+
.category-fix { color: var(--orange); background: rgba(250,179,135,0.15); }
|
|
1365
|
+
.category-style { color: var(--purple); background: rgba(203,166,247,0.15); }
|
|
1366
|
+
.category-pattern-follow { color: var(--green); background: rgba(166,227,161,0.15); }
|
|
1367
|
+
.category-pattern-avoid { color: var(--red); background: rgba(243,139,168,0.15); }
|
|
1368
|
+
.category-note { color: var(--blue); background: rgba(137,180,250,0.15); }
|
|
1369
|
+
.category-remember { color: var(--yellow); background: rgba(249,226,175,0.15); }
|
|
1370
|
+
|
|
1371
|
+
.annotation-text {
|
|
1372
|
+
flex: 1;
|
|
1373
|
+
color: var(--text);
|
|
1374
|
+
line-height: 1.4;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
.annotation-actions {
|
|
1378
|
+
display: flex;
|
|
1379
|
+
gap: 4px;
|
|
1380
|
+
flex-shrink: 0;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/* Stale annotations */
|
|
1384
|
+
.annotation-stale {
|
|
1385
|
+
opacity: 0.7;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
.annotation-row:has(.annotation-stale) {
|
|
1389
|
+
border-left-color: var(--orange);
|
|
1390
|
+
background: rgba(250,179,135,0.05);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
.btn-keep { color: var(--orange); }
|
|
1394
|
+
.btn-keep:hover { background: rgba(250,179,135,0.15); }
|
|
1395
|
+
|
|
1396
|
+
.btn-icon { display: inline-flex; align-items: center; justify-content: center; padding: 3px 4px; }
|
|
1397
|
+
.btn-icon svg { display: block; }
|
|
1398
|
+
|
|
1399
|
+
.annotation-category[data-action="reclassify"] { cursor: pointer; }
|
|
1400
|
+
.annotation-category[data-action="reclassify"]:hover { filter: brightness(1.3); }
|
|
1401
|
+
|
|
1402
|
+
.reclassify-popup {
|
|
1403
|
+
background: var(--bg-surface);
|
|
1404
|
+
border: 1px solid var(--border);
|
|
1405
|
+
border-radius: var(--radius);
|
|
1406
|
+
padding: 4px 0;
|
|
1407
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
1408
|
+
min-width: 140px;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
.reclassify-option {
|
|
1412
|
+
padding: 6px 12px;
|
|
1413
|
+
cursor: pointer;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.reclassify-option:hover {
|
|
1417
|
+
background: var(--bg-hover);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
.reclassify-option.active {
|
|
1421
|
+
background: rgba(137,180,250,0.1);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/* Stale indicator dot */
|
|
1425
|
+
.stale-dot {
|
|
1426
|
+
width: 6px;
|
|
1427
|
+
height: 6px;
|
|
1428
|
+
border-radius: 50%;
|
|
1429
|
+
background: var(--orange);
|
|
1430
|
+
flex-shrink: 0;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/* Drag handle */
|
|
1434
|
+
.annotation-drag-handle {
|
|
1435
|
+
cursor: grab;
|
|
1436
|
+
color: var(--text-dim);
|
|
1437
|
+
font-size: 14px;
|
|
1438
|
+
line-height: 1;
|
|
1439
|
+
flex-shrink: 0;
|
|
1440
|
+
user-select: none;
|
|
1441
|
+
padding: 0 2px;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
.annotation-drag-handle:hover {
|
|
1445
|
+
color: var(--text);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/* Drop target highlight */
|
|
1449
|
+
.diff-line.drag-over {
|
|
1450
|
+
outline: 2px solid var(--accent);
|
|
1451
|
+
outline-offset: -2px;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/* Annotation form */
|
|
1455
|
+
.annotation-form-container {
|
|
1456
|
+
padding: 12px 16px 12px 76px;
|
|
1457
|
+
background: rgba(137,180,250,0.08);
|
|
1458
|
+
border-left: 3px solid var(--accent);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
.annotation-form {
|
|
1462
|
+
display: flex;
|
|
1463
|
+
flex-direction: column;
|
|
1464
|
+
gap: 8px;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
.form-category-badge {
|
|
1468
|
+
cursor: pointer;
|
|
1469
|
+
align-self: flex-start;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
.form-category-badge:hover {
|
|
1473
|
+
filter: brightness(1.3);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
.annotation-form textarea {
|
|
1477
|
+
width: 100%;
|
|
1478
|
+
min-height: 60px;
|
|
1479
|
+
padding: 8px;
|
|
1480
|
+
background: var(--bg);
|
|
1481
|
+
color: var(--text);
|
|
1482
|
+
border: 1px solid var(--border);
|
|
1483
|
+
border-radius: var(--radius);
|
|
1484
|
+
font-family: var(--font-sans);
|
|
1485
|
+
font-size: 13px;
|
|
1486
|
+
resize: vertical;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
.annotation-form textarea:focus {
|
|
1490
|
+
outline: none;
|
|
1491
|
+
border-color: var(--accent);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
.annotation-form-actions {
|
|
1495
|
+
display: flex;
|
|
1496
|
+
gap: 8px;
|
|
1497
|
+
justify-content: flex-end;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/* Buttons */
|
|
1501
|
+
.btn {
|
|
1502
|
+
padding: 6px 14px;
|
|
1503
|
+
border-radius: var(--radius);
|
|
1504
|
+
border: 1px solid var(--border);
|
|
1505
|
+
background: var(--bg-surface);
|
|
1506
|
+
color: var(--text);
|
|
1507
|
+
cursor: pointer;
|
|
1508
|
+
font-size: 13px;
|
|
1509
|
+
transition: background 0.15s;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
.btn:hover { background: var(--bg-hover); }
|
|
1513
|
+
.btn.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
|
1514
|
+
|
|
1515
|
+
.btn-sm { padding: 3px 10px; font-size: 12px; }
|
|
1516
|
+
.btn-xs { padding: 2px 6px; font-size: 11px; }
|
|
1517
|
+
|
|
1518
|
+
.btn-primary {
|
|
1519
|
+
background: var(--accent);
|
|
1520
|
+
color: var(--bg);
|
|
1521
|
+
border-color: var(--accent);
|
|
1522
|
+
font-weight: 600;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
.btn-primary:hover { background: var(--accent-hover); }
|
|
1526
|
+
|
|
1527
|
+
.btn-danger { color: var(--red); }
|
|
1528
|
+
.btn-danger:hover { background: rgba(243,139,168,0.15); }
|
|
1529
|
+
|
|
1530
|
+
.btn-link {
|
|
1531
|
+
background: none;
|
|
1532
|
+
border: none;
|
|
1533
|
+
color: var(--accent);
|
|
1534
|
+
text-decoration: none;
|
|
1535
|
+
text-align: center;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.btn-link:hover { text-decoration: underline; }
|
|
1539
|
+
|
|
1540
|
+
/* History */
|
|
1541
|
+
body:has(.history-page) {
|
|
1542
|
+
overflow: auto;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
.history-page {
|
|
1546
|
+
max-width: 800px;
|
|
1547
|
+
margin: 0 auto;
|
|
1548
|
+
padding: 40px 20px;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
.history-page h1 { margin-bottom: 24px; }
|
|
1552
|
+
|
|
1553
|
+
.history-item {
|
|
1554
|
+
padding: 16px;
|
|
1555
|
+
background: var(--bg-surface);
|
|
1556
|
+
border: 1px solid var(--border);
|
|
1557
|
+
border-radius: var(--radius);
|
|
1558
|
+
margin-bottom: 12px;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
.history-item {
|
|
1562
|
+
position: relative;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
.history-item h3 { font-size: 15px; margin-bottom: 4px; padding-right: 32px; }
|
|
1566
|
+
.history-item .meta { font-size: 12px; color: var(--text-dim); }
|
|
1567
|
+
|
|
1568
|
+
.history-item .delete-review-btn {
|
|
1569
|
+
position: absolute;
|
|
1570
|
+
top: 12px;
|
|
1571
|
+
right: 12px;
|
|
1572
|
+
background: none;
|
|
1573
|
+
border: none;
|
|
1574
|
+
color: var(--text-dim);
|
|
1575
|
+
cursor: pointer;
|
|
1576
|
+
padding: 4px;
|
|
1577
|
+
border-radius: var(--radius);
|
|
1578
|
+
font-size: 16px;
|
|
1579
|
+
line-height: 1;
|
|
1580
|
+
opacity: 0;
|
|
1581
|
+
transition: opacity 0.15s, color 0.15s;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.history-item:hover .delete-review-btn { opacity: 1; }
|
|
1585
|
+
.history-item .delete-review-btn:hover { color: var(--red); background: rgba(243,139,168,0.15); }
|
|
1586
|
+
|
|
1587
|
+
.bulk-actions {
|
|
1588
|
+
display: flex;
|
|
1589
|
+
gap: 8px;
|
|
1590
|
+
margin: 12px 0;
|
|
1591
|
+
padding: 4px 0;
|
|
1592
|
+
align-items: center;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
.bulk-actions span {
|
|
1596
|
+
font-size: 13px;
|
|
1597
|
+
color: var(--text-dim);
|
|
1598
|
+
margin-right: auto;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
.status-badge {
|
|
1602
|
+
display: inline-block;
|
|
1603
|
+
font-size: 11px;
|
|
1604
|
+
padding: 1px 8px;
|
|
1605
|
+
border-radius: 10px;
|
|
1606
|
+
font-weight: 500;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
.status-badge.in_progress, .status-badge.in-progress { color: var(--yellow); background: rgba(249,226,175,0.15); }
|
|
1610
|
+
.status-badge.completed { color: var(--green); background: rgba(166,227,161,0.15); }
|
|
1611
|
+
|
|
1612
|
+
.history-item-link {
|
|
1613
|
+
text-decoration: none;
|
|
1614
|
+
color: inherit;
|
|
1615
|
+
display: block;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
.history-item-link:hover .history-item {
|
|
1619
|
+
border-color: var(--accent);
|
|
1620
|
+
background: var(--bg-hover);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
.expanded-context {
|
|
1624
|
+
background: rgba(137,180,250,0.03);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/* Wrap mode */
|
|
1628
|
+
.wrap-lines .code {
|
|
1629
|
+
white-space: pre-wrap !important;
|
|
1630
|
+
word-break: break-all;
|
|
1631
|
+
overflow-x: visible !important;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/* Hide horizontal scrollbars on code cells (still scrollable via trackpad) */
|
|
1635
|
+
.diff-line .code {
|
|
1636
|
+
scrollbar-width: none;
|
|
1637
|
+
}
|
|
1638
|
+
.diff-line .code::-webkit-scrollbar {
|
|
1639
|
+
height: 0;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/* Controls layout */
|
|
1643
|
+
.sidebar-controls-row {
|
|
1644
|
+
display: flex;
|
|
1645
|
+
gap: 8px;
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
.controls-divider {
|
|
1650
|
+
width: 1px;
|
|
1651
|
+
height: 18px;
|
|
1652
|
+
background: var(--border);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/* Complete modal */
|
|
1656
|
+
.modal-overlay {
|
|
1657
|
+
position: fixed;
|
|
1658
|
+
inset: 0;
|
|
1659
|
+
background: rgba(0,0,0,0.6);
|
|
1660
|
+
display: flex;
|
|
1661
|
+
align-items: center;
|
|
1662
|
+
justify-content: center;
|
|
1663
|
+
z-index: 100;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
.modal {
|
|
1667
|
+
background: var(--bg-surface);
|
|
1668
|
+
border: 1px solid var(--border);
|
|
1669
|
+
border-radius: 12px;
|
|
1670
|
+
padding: 24px;
|
|
1671
|
+
max-width: 480px;
|
|
1672
|
+
width: 90%;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
.modal h3 { margin-bottom: 12px; }
|
|
1676
|
+
.modal p { margin-bottom: 16px; color: var(--text-dim); font-size: 14px; }
|
|
1677
|
+
|
|
1678
|
+
.modal-label { margin-bottom: 4px !important; font-size: 13px !important; color: var(--text-dim) !important; }
|
|
1679
|
+
|
|
1680
|
+
.modal-copyable {
|
|
1681
|
+
font-family: var(--font-mono);
|
|
1682
|
+
font-size: 12px;
|
|
1683
|
+
color: var(--accent);
|
|
1684
|
+
background: var(--bg);
|
|
1685
|
+
padding: 8px 12px;
|
|
1686
|
+
border-radius: var(--radius);
|
|
1687
|
+
border: 1px solid var(--border);
|
|
1688
|
+
margin-bottom: 16px;
|
|
1689
|
+
cursor: pointer;
|
|
1690
|
+
position: relative;
|
|
1691
|
+
word-break: break-all;
|
|
1692
|
+
transition: border-color 0.15s;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
.modal-copyable:hover { border-color: var(--accent); }
|
|
1696
|
+
|
|
1697
|
+
.modal-copyable.copied::after {
|
|
1698
|
+
content: 'Copied!';
|
|
1699
|
+
position: absolute;
|
|
1700
|
+
right: 8px;
|
|
1701
|
+
top: 50%;
|
|
1702
|
+
transform: translateY(-50%);
|
|
1703
|
+
font-size: 11px;
|
|
1704
|
+
color: var(--green);
|
|
1705
|
+
font-family: var(--font-sans);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
.modal-gitignore {
|
|
1709
|
+
margin-bottom: 16px;
|
|
1710
|
+
padding-top: 12px;
|
|
1711
|
+
border-top: 1px solid var(--border);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
.modal-actions {
|
|
1715
|
+
display: flex;
|
|
1716
|
+
gap: 8px;
|
|
1717
|
+
justify-content: flex-end;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/* Scrollbar */
|
|
1721
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
1722
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1723
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
1724
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
|
1725
|
+
|
|
1726
|
+
/* Progress */
|
|
1727
|
+
.progress-bar {
|
|
1728
|
+
height: 3px;
|
|
1729
|
+
background: var(--border);
|
|
1730
|
+
border-radius: 2px;
|
|
1731
|
+
overflow: hidden;
|
|
1732
|
+
margin: 8px 16px;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
.progress-bar-fill {
|
|
1736
|
+
height: 100%;
|
|
1737
|
+
background: var(--accent);
|
|
1738
|
+
transition: width 0.3s;
|
|
1739
|
+
}
|
|
1740
|
+
`;
|
|
1741
|
+
}
|
|
1742
|
+
function getClientScript() {
|
|
1743
|
+
return `
|
|
1744
|
+
(function() {
|
|
1745
|
+
const state = {
|
|
1746
|
+
reviewId: document.body.dataset.reviewId,
|
|
1747
|
+
currentFileId: null,
|
|
1748
|
+
diffMode: 'split',
|
|
1749
|
+
wrapLines: false,
|
|
1750
|
+
files: [],
|
|
1751
|
+
fileOrder: [],
|
|
1752
|
+
annotationCounts: {},
|
|
1753
|
+
staleCounts: {},
|
|
1754
|
+
filterText: '',
|
|
1755
|
+
_dragAnnotation: null,
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
const CATEGORIES = [
|
|
1759
|
+
{ value: 'bug', label: 'Bug' },
|
|
1760
|
+
{ value: 'fix', label: 'Fix needed' },
|
|
1761
|
+
{ value: 'style', label: 'Style' },
|
|
1762
|
+
{ value: 'pattern-follow', label: 'Pattern to follow' },
|
|
1763
|
+
{ value: 'pattern-avoid', label: 'Pattern to avoid' },
|
|
1764
|
+
{ value: 'note', label: 'Note' },
|
|
1765
|
+
{ value: 'remember', label: 'Remember (for AI)' },
|
|
1766
|
+
];
|
|
1767
|
+
|
|
1768
|
+
// --- API ---
|
|
1769
|
+
async function api(path, opts = {}) {
|
|
1770
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
1771
|
+
const url = '/api' + path + separator + 'reviewId=' + encodeURIComponent(state.reviewId);
|
|
1772
|
+
const res = await fetch(url, {
|
|
1773
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1774
|
+
...opts,
|
|
1775
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
1776
|
+
});
|
|
1777
|
+
return res.json();
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// --- Init ---
|
|
1781
|
+
async function init() {
|
|
1782
|
+
await loadFiles();
|
|
1783
|
+
bindSidebarEvents();
|
|
1784
|
+
bindDiffModeToggle();
|
|
1785
|
+
bindWrapToggle();
|
|
1786
|
+
bindFileFilter();
|
|
1787
|
+
bindSidebarResize();
|
|
1788
|
+
bindCompleteButton();
|
|
1789
|
+
bindReopenButton();
|
|
1790
|
+
initScrollSync();
|
|
1791
|
+
updateProgress();
|
|
1792
|
+
document.addEventListener('dragend', () => {
|
|
1793
|
+
state._dragAnnotation = null;
|
|
1794
|
+
document.querySelectorAll('.diff-line.drag-over').forEach(d => d.classList.remove('drag-over'));
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
async function loadFiles() {
|
|
1799
|
+
const data = await api('/files');
|
|
1800
|
+
state.files = data.files;
|
|
1801
|
+
state.annotationCounts = data.annotationCounts;
|
|
1802
|
+
state.staleCounts = data.staleCounts || {};
|
|
1803
|
+
renderFileList();
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function renderFileList() {
|
|
1807
|
+
var list = document.querySelector('.file-list-items');
|
|
1808
|
+
if (!list) return;
|
|
1809
|
+
list.innerHTML = '';
|
|
1810
|
+
state.fileOrder = [];
|
|
1811
|
+
var filtered = state.files;
|
|
1812
|
+
if (state.filterText) {
|
|
1813
|
+
var q = state.filterText.toLowerCase();
|
|
1814
|
+
filtered = state.files.filter(function(f) {
|
|
1815
|
+
return f.file_path.toLowerCase().indexOf(q) !== -1;
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
var tree = buildFileTree(filtered);
|
|
1819
|
+
renderTreeNode(list, tree, 0);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function buildFileTree(files) {
|
|
1823
|
+
var root = { name: '', children: [], files: [] };
|
|
1824
|
+
files.forEach(function(f) {
|
|
1825
|
+
var parts = f.file_path.split('/');
|
|
1826
|
+
var node = root;
|
|
1827
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
1828
|
+
var child = node.children.find(function(c) { return c.name === parts[i]; });
|
|
1829
|
+
if (!child) {
|
|
1830
|
+
child = { name: parts[i], children: [], files: [] };
|
|
1831
|
+
node.children.push(child);
|
|
1832
|
+
}
|
|
1833
|
+
node = child;
|
|
1834
|
+
}
|
|
1835
|
+
node.files.push(f);
|
|
1836
|
+
});
|
|
1837
|
+
compressTree(root);
|
|
1838
|
+
return root;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function compressTree(node) {
|
|
1842
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
1843
|
+
var child = node.children[i];
|
|
1844
|
+
while (child.children.length === 1 && child.files.length === 0) {
|
|
1845
|
+
var gc = child.children[0];
|
|
1846
|
+
child = { name: child.name + '/' + gc.name, children: gc.children, files: gc.files };
|
|
1847
|
+
node.children[i] = child;
|
|
1848
|
+
}
|
|
1849
|
+
compressTree(child);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function countTreeFiles(node) {
|
|
1854
|
+
var count = node.files.length;
|
|
1855
|
+
node.children.forEach(function(c) { count += countTreeFiles(c); });
|
|
1856
|
+
return count;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function hasStaleInTree(node) {
|
|
1860
|
+
for (var i = 0; i < node.files.length; i++) {
|
|
1861
|
+
if (state.staleCounts[node.files[i].id]) return true;
|
|
1862
|
+
}
|
|
1863
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
1864
|
+
if (hasStaleInTree(node.children[i])) return true;
|
|
1865
|
+
}
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function renderTreeNode(container, node, depth) {
|
|
1870
|
+
var sortedChildren = node.children.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
|
|
1871
|
+
|
|
1872
|
+
sortedChildren.forEach(function(child) {
|
|
1873
|
+
var total = countTreeFiles(child);
|
|
1874
|
+
var isCollapsible = total > 1;
|
|
1875
|
+
|
|
1876
|
+
var group = document.createElement('div');
|
|
1877
|
+
group.className = 'folder-group';
|
|
1878
|
+
|
|
1879
|
+
var header = document.createElement('div');
|
|
1880
|
+
header.className = 'folder-header' + (isCollapsible ? ' collapsible' : '');
|
|
1881
|
+
header.style.paddingLeft = (16 + depth * 12) + 'px';
|
|
1882
|
+
var stale = hasStaleInTree(child);
|
|
1883
|
+
header.innerHTML =
|
|
1884
|
+
(isCollapsible ? '<span class="folder-arrow">\u25BE</span>' : '<span class="folder-arrow-spacer"></span>') +
|
|
1885
|
+
'<span class="folder-name">' + esc(child.name) + '/</span>' +
|
|
1886
|
+
(stale ? '<span class="stale-dot"></span>' : '');
|
|
1887
|
+
|
|
1888
|
+
var content = document.createElement('div');
|
|
1889
|
+
content.className = 'folder-content';
|
|
1890
|
+
|
|
1891
|
+
if (isCollapsible) {
|
|
1892
|
+
header.addEventListener('click', function() {
|
|
1893
|
+
header.classList.toggle('collapsed');
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
renderTreeNode(content, child, depth + 1);
|
|
1898
|
+
|
|
1899
|
+
group.appendChild(header);
|
|
1900
|
+
group.appendChild(content);
|
|
1901
|
+
container.appendChild(group);
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
node.files.forEach(function(f) {
|
|
1905
|
+
var diff = JSON.parse(f.diff_data || '{}');
|
|
1906
|
+
var el = document.createElement('div');
|
|
1907
|
+
el.className = 'file-item' + (f.id === state.currentFileId ? ' active' : '');
|
|
1908
|
+
el.dataset.fileId = f.id;
|
|
1909
|
+
el.style.paddingLeft = (16 + depth * 12) + 'px';
|
|
1910
|
+
var count = state.annotationCounts[f.id] || 0;
|
|
1911
|
+
var staleCount = state.staleCounts[f.id] || 0;
|
|
1912
|
+
var fileName = f.file_path.split('/').pop();
|
|
1913
|
+
el.innerHTML =
|
|
1914
|
+
'<span class="status-dot ' + f.status + '"></span>' +
|
|
1915
|
+
'<span class="file-name" title="' + esc(f.file_path) + '">' + esc(fileName) + '</span>' +
|
|
1916
|
+
'<span class="file-status ' + (diff.status || '') + '">' + (diff.status || '') + '</span>' +
|
|
1917
|
+
(staleCount ? '<span class="stale-dot"></span>' : '') +
|
|
1918
|
+
(count ? '<span class="annotation-count">' + count + '</span>' : '');
|
|
1919
|
+
el.addEventListener('click', function() { selectFile(f.id); });
|
|
1920
|
+
container.appendChild(el);
|
|
1921
|
+
state.fileOrder.push(f.id);
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function esc(s) {
|
|
1926
|
+
const d = document.createElement('div');
|
|
1927
|
+
d.textContent = s;
|
|
1928
|
+
return d.innerHTML;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// --- File Selection ---
|
|
1932
|
+
async function selectFile(fileId) {
|
|
1933
|
+
state.currentFileId = fileId;
|
|
1934
|
+
document.querySelectorAll('.file-item').forEach(el => {
|
|
1935
|
+
el.classList.toggle('active', el.dataset.fileId === fileId);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
const container = document.getElementById('diff-container');
|
|
1939
|
+
const welcome = document.querySelector('.welcome-message');
|
|
1940
|
+
if (welcome) welcome.style.display = 'none';
|
|
1941
|
+
container.style.display = 'block';
|
|
1942
|
+
|
|
1943
|
+
const res = await fetch('/file/' + fileId + '?mode=' + state.diffMode);
|
|
1944
|
+
container.innerHTML = await res.text();
|
|
1945
|
+
|
|
1946
|
+
// Reapply wrap mode
|
|
1947
|
+
container.classList.toggle('wrap-lines', state.wrapLines);
|
|
1948
|
+
|
|
1949
|
+
// Mark file as reviewed
|
|
1950
|
+
const file = state.files.find(f => f.id === fileId);
|
|
1951
|
+
if (file && file.status === 'pending') {
|
|
1952
|
+
await api('/files/' + fileId + '/status', { method: 'PATCH', body: { status: 'reviewed' } });
|
|
1953
|
+
file.status = 'reviewed';
|
|
1954
|
+
renderFileList();
|
|
1955
|
+
updateProgress();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
bindDiffLineClicks();
|
|
1959
|
+
bindHunkExpanders();
|
|
1960
|
+
bindDragDrop();
|
|
1961
|
+
bindServerAnnotations();
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// --- Context Expansion ---
|
|
1965
|
+
function bindHunkExpanders() {
|
|
1966
|
+
document.querySelectorAll('.hunk-separator').forEach(el => {
|
|
1967
|
+
el.addEventListener('click', async () => {
|
|
1968
|
+
const fileId = document.querySelector('.diff-view')?.dataset?.fileId;
|
|
1969
|
+
if (!fileId) return;
|
|
1970
|
+
|
|
1971
|
+
const hunkBlock = el.closest('.hunk-block');
|
|
1972
|
+
const prevBlock = hunkBlock?.previousElementSibling;
|
|
1973
|
+
|
|
1974
|
+
// Determine the gap: from end of previous hunk to start of this hunk
|
|
1975
|
+
const newStart = parseInt(el.dataset.newStart, 10);
|
|
1976
|
+
let gapStart = 1;
|
|
1977
|
+
if (prevBlock) {
|
|
1978
|
+
const prevSep = prevBlock.querySelector('.hunk-separator');
|
|
1979
|
+
if (prevSep) {
|
|
1980
|
+
gapStart = parseInt(prevSep.dataset.newStart, 10) + parseInt(prevSep.dataset.newCount, 10);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const gapEnd = newStart - 1;
|
|
1984
|
+
if (gapEnd < gapStart) return;
|
|
1985
|
+
|
|
1986
|
+
const data = await api('/context/' + fileId + '?start=' + gapStart + '&end=' + gapEnd);
|
|
1987
|
+
if (!data.lines || !data.lines.length) return;
|
|
1988
|
+
|
|
1989
|
+
// Build context lines and insert before the hunk separator
|
|
1990
|
+
const fragment = document.createDocumentFragment();
|
|
1991
|
+
data.lines.forEach(line => {
|
|
1992
|
+
const wrapper = document.createElement('div');
|
|
1993
|
+
const lineEl = document.createElement('div');
|
|
1994
|
+
lineEl.className = 'diff-line context expanded-context';
|
|
1995
|
+
lineEl.dataset.line = line.num;
|
|
1996
|
+
lineEl.dataset.side = 'new';
|
|
1997
|
+
|
|
1998
|
+
if (state.diffMode === 'split') {
|
|
1999
|
+
lineEl.innerHTML =
|
|
2000
|
+
'<span class="gutter">' + line.num + '</span>' +
|
|
2001
|
+
'<span class="code">' + esc(line.content) + '</span>';
|
|
2002
|
+
const row = document.createElement('div');
|
|
2003
|
+
row.className = 'split-row';
|
|
2004
|
+
const leftEl = lineEl.cloneNode(true);
|
|
2005
|
+
leftEl.dataset.side = 'old';
|
|
2006
|
+
row.appendChild(leftEl);
|
|
2007
|
+
row.appendChild(lineEl);
|
|
2008
|
+
wrapper.appendChild(row);
|
|
2009
|
+
} else {
|
|
2010
|
+
lineEl.innerHTML =
|
|
2011
|
+
'<span class="gutter-old">' + line.num + '</span>' +
|
|
2012
|
+
'<span class="gutter-new">' + line.num + '</span>' +
|
|
2013
|
+
'<span class="code">' + esc(line.content) + '</span>';
|
|
2014
|
+
wrapper.appendChild(lineEl);
|
|
2015
|
+
}
|
|
2016
|
+
fragment.appendChild(wrapper);
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
el.replaceWith(fragment);
|
|
2020
|
+
bindDiffLineClicks();
|
|
2021
|
+
});
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// --- Diff Line Clicks ---
|
|
2026
|
+
function bindDiffLineClicks() {
|
|
2027
|
+
document.querySelectorAll('.diff-line').forEach(el => {
|
|
2028
|
+
el.addEventListener('click', (e) => {
|
|
2029
|
+
if (e.target.closest('.annotation-form-container') || e.target.closest('.annotation-row')) return;
|
|
2030
|
+
const line = parseInt(el.dataset.line, 10);
|
|
2031
|
+
const side = el.dataset.side || 'new';
|
|
2032
|
+
if (!isNaN(line)) showAnnotationForm(el, line, side);
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function buildCategoryBadge(value) {
|
|
2038
|
+
var cat = CATEGORIES.find(function(c) { return c.value === value; });
|
|
2039
|
+
return '<span class="annotation-category category-' + esc(value) + ' form-category-badge" data-category="' + esc(value) + '">' + esc(cat ? cat.label : value) + '</span>';
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function bindCategoryBadgeClick(container) {
|
|
2043
|
+
var badge = container.querySelector('.form-category-badge');
|
|
2044
|
+
if (!badge) return;
|
|
2045
|
+
badge.addEventListener('click', function(e) {
|
|
2046
|
+
e.stopPropagation();
|
|
2047
|
+
showCategoryPicker(badge);
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function showCategoryPicker(badge) {
|
|
2052
|
+
document.querySelectorAll('.reclassify-popup').forEach(function(el) { el.remove(); });
|
|
2053
|
+
|
|
2054
|
+
var current = badge.dataset.category;
|
|
2055
|
+
var popup = document.createElement('div');
|
|
2056
|
+
popup.className = 'reclassify-popup';
|
|
2057
|
+
popup.innerHTML = CATEGORIES.map(function(c) {
|
|
2058
|
+
return '<div class="reclassify-option' + (c.value === current ? ' active' : '') + '" data-value="' + c.value + '">' +
|
|
2059
|
+
'<span class="annotation-category category-' + c.value + '">' + c.label + '</span>' +
|
|
2060
|
+
'</div>';
|
|
2061
|
+
}).join('');
|
|
2062
|
+
|
|
2063
|
+
var rect = badge.getBoundingClientRect();
|
|
2064
|
+
popup.style.position = 'fixed';
|
|
2065
|
+
popup.style.left = rect.left + 'px';
|
|
2066
|
+
popup.style.top = (rect.bottom + 4) + 'px';
|
|
2067
|
+
popup.style.zIndex = '1000';
|
|
2068
|
+
|
|
2069
|
+
popup.addEventListener('click', function(e) {
|
|
2070
|
+
var opt = e.target.closest('.reclassify-option');
|
|
2071
|
+
if (!opt) return;
|
|
2072
|
+
e.stopPropagation();
|
|
2073
|
+
var val = opt.dataset.value;
|
|
2074
|
+
var cat = CATEGORIES.find(function(c) { return c.value === val; });
|
|
2075
|
+
badge.className = 'annotation-category category-' + val + ' form-category-badge';
|
|
2076
|
+
badge.dataset.category = val;
|
|
2077
|
+
badge.textContent = cat ? cat.label : val;
|
|
2078
|
+
popup.remove();
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
document.body.appendChild(popup);
|
|
2082
|
+
var closePopup = function(e) {
|
|
2083
|
+
if (!popup.contains(e.target)) {
|
|
2084
|
+
popup.remove();
|
|
2085
|
+
document.removeEventListener('click', closePopup, true);
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
setTimeout(function() { document.addEventListener('click', closePopup, true); }, 0);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function showAnnotationForm(afterEl, lineNumber, side) {
|
|
2092
|
+
// Remove any existing form
|
|
2093
|
+
document.querySelectorAll('.annotation-form-container').forEach(el => el.remove());
|
|
2094
|
+
|
|
2095
|
+
var defaultCategory = CATEGORIES[0].value;
|
|
2096
|
+
const container = document.createElement('div');
|
|
2097
|
+
container.className = 'annotation-form-container';
|
|
2098
|
+
container.innerHTML =
|
|
2099
|
+
'<div class="annotation-form">' +
|
|
2100
|
+
buildCategoryBadge(defaultCategory) +
|
|
2101
|
+
'<textarea placeholder="Enter your annotation..." autofocus></textarea>' +
|
|
2102
|
+
'<div class="annotation-form-actions">' +
|
|
2103
|
+
'<button class="btn btn-sm cancel-btn">Cancel</button>' +
|
|
2104
|
+
'<button class="btn btn-sm btn-primary annotation-save-btn">Save</button>' +
|
|
2105
|
+
'</div>' +
|
|
2106
|
+
'</div>';
|
|
2107
|
+
|
|
2108
|
+
// Insert after the clicked line (or after its annotation rows)
|
|
2109
|
+
let insertAfter = afterEl;
|
|
2110
|
+
let next = afterEl.nextElementSibling;
|
|
2111
|
+
while (next && (next.classList.contains('annotation-row'))) {
|
|
2112
|
+
insertAfter = next;
|
|
2113
|
+
next = next.nextElementSibling;
|
|
2114
|
+
}
|
|
2115
|
+
insertAfter.parentNode.insertBefore(container, insertAfter.nextSibling);
|
|
2116
|
+
|
|
2117
|
+
bindCategoryBadgeClick(container);
|
|
2118
|
+
|
|
2119
|
+
container.querySelector('.cancel-btn').addEventListener('click', () => {
|
|
2120
|
+
container.remove();
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
const textarea = container.querySelector('textarea');
|
|
2124
|
+
textarea.focus();
|
|
2125
|
+
|
|
2126
|
+
// Handle Cmd/Ctrl+Enter to save
|
|
2127
|
+
textarea.addEventListener('keydown', (e) => {
|
|
2128
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
2129
|
+
saveAnnotation(container, lineNumber, side);
|
|
2130
|
+
}
|
|
2131
|
+
if (e.key === 'Escape') {
|
|
2132
|
+
container.remove();
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
container.querySelector('.annotation-save-btn').addEventListener('click', () => {
|
|
2137
|
+
saveAnnotation(container, lineNumber, side);
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
async function saveAnnotation(container, lineNumber, side) {
|
|
2142
|
+
const content = container.querySelector('textarea').value.trim();
|
|
2143
|
+
const category = container.querySelector('.form-category-badge').dataset.category;
|
|
2144
|
+
if (!content) return;
|
|
2145
|
+
|
|
2146
|
+
const annotation = await api('/annotations', {
|
|
2147
|
+
method: 'POST',
|
|
2148
|
+
body: {
|
|
2149
|
+
reviewFileId: state.currentFileId,
|
|
2150
|
+
lineNumber,
|
|
2151
|
+
side,
|
|
2152
|
+
category,
|
|
2153
|
+
content,
|
|
2154
|
+
},
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
container.remove();
|
|
2158
|
+
renderAnnotationInline(annotation, lineNumber, side);
|
|
2159
|
+
|
|
2160
|
+
// Update count
|
|
2161
|
+
state.annotationCounts[state.currentFileId] = (state.annotationCounts[state.currentFileId] || 0) + 1;
|
|
2162
|
+
renderFileList();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function renderAnnotationInline(annotation, lineNumber, side) {
|
|
2166
|
+
// Find the line element
|
|
2167
|
+
const lineEl = document.querySelector('.diff-line[data-line="' + lineNumber + '"][data-side="' + side + '"]');
|
|
2168
|
+
if (!lineEl) return;
|
|
2169
|
+
|
|
2170
|
+
lineEl.classList.add('has-annotation');
|
|
2171
|
+
|
|
2172
|
+
// Find or create annotation row after this line
|
|
2173
|
+
let annotationRow = lineEl.nextElementSibling;
|
|
2174
|
+
if (!annotationRow || !annotationRow.classList.contains('annotation-row')) {
|
|
2175
|
+
annotationRow = document.createElement('div');
|
|
2176
|
+
annotationRow.className = 'annotation-row';
|
|
2177
|
+
lineEl.parentNode.insertBefore(annotationRow, lineEl.nextSibling);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const item = document.createElement('div');
|
|
2181
|
+
item.className = 'annotation-item' + (annotation.is_stale ? ' annotation-stale' : '');
|
|
2182
|
+
item.dataset.annotationId = annotation.id;
|
|
2183
|
+
if (annotation.is_stale) item.dataset.isStale = 'true';
|
|
2184
|
+
item.innerHTML = buildAnnotationItemHtml(annotation);
|
|
2185
|
+
|
|
2186
|
+
bindAnnotationItemEvents(item, annotation, lineEl, annotationRow);
|
|
2187
|
+
annotationRow.appendChild(item);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
var ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
2191
|
+
var ICON_EDIT = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>';
|
|
2192
|
+
|
|
2193
|
+
function buildAnnotationItemHtml(annotation) {
|
|
2194
|
+
return '<span class="annotation-drag-handle" draggable="true" title="Drag to move">\u2807</span>' +
|
|
2195
|
+
'<span class="annotation-category category-' + esc(annotation.category) + '" data-action="reclassify">' + esc(annotation.category) + '</span>' +
|
|
2196
|
+
'<span class="annotation-text">' + esc(annotation.content) + '</span>' +
|
|
2197
|
+
'<div class="annotation-actions">' +
|
|
2198
|
+
(annotation.is_stale ? '<button class="btn btn-xs btn-keep" data-action="keep">Keep</button>' : '') +
|
|
2199
|
+
'<button class="btn btn-xs btn-icon" data-action="edit" title="Edit">' + ICON_EDIT + '</button>' +
|
|
2200
|
+
'<button class="btn btn-xs btn-icon btn-danger" data-action="delete" title="Delete">' + ICON_TRASH + '</button>' +
|
|
2201
|
+
'</div>';
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function bindAnnotationItemEvents(item, annotation, lineEl, annotationRow) {
|
|
2205
|
+
item.querySelector('[data-action="delete"]')?.addEventListener('click', async (e) => {
|
|
2206
|
+
e.stopPropagation();
|
|
2207
|
+
await api('/annotations/' + annotation.id, { method: 'DELETE' });
|
|
2208
|
+
item.remove();
|
|
2209
|
+
if (annotationRow && !annotationRow.querySelector('.annotation-item')) {
|
|
2210
|
+
annotationRow.remove();
|
|
2211
|
+
if (lineEl) lineEl.classList.remove('has-annotation');
|
|
2212
|
+
}
|
|
2213
|
+
state.annotationCounts[state.currentFileId] = Math.max(0, (state.annotationCounts[state.currentFileId] || 1) - 1);
|
|
2214
|
+
if (annotation.is_stale) {
|
|
2215
|
+
state.staleCounts[state.currentFileId] = Math.max(0, (state.staleCounts[state.currentFileId] || 1) - 1);
|
|
2216
|
+
}
|
|
2217
|
+
renderFileList();
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
item.querySelector('[data-action="edit"]')?.addEventListener('click', (e) => {
|
|
2221
|
+
e.stopPropagation();
|
|
2222
|
+
editAnnotation(item, annotation);
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
// Double-click to edit
|
|
2226
|
+
item.addEventListener('dblclick', (e) => {
|
|
2227
|
+
e.stopPropagation();
|
|
2228
|
+
if (item.querySelector('.annotation-form')) return;
|
|
2229
|
+
editAnnotation(item, annotation);
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
// Click category to reclassify
|
|
2233
|
+
item.querySelector('[data-action="reclassify"]')?.addEventListener('click', (e) => {
|
|
2234
|
+
e.stopPropagation();
|
|
2235
|
+
showReclassifyPopup(e.target.closest('[data-action="reclassify"]'), item, annotation);
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
item.querySelector('[data-action="keep"]')?.addEventListener('click', async (e) => {
|
|
2239
|
+
e.stopPropagation();
|
|
2240
|
+
await api('/annotations/' + annotation.id + '/keep', { method: 'POST' });
|
|
2241
|
+
annotation.is_stale = false;
|
|
2242
|
+
item.classList.remove('annotation-stale');
|
|
2243
|
+
delete item.dataset.isStale;
|
|
2244
|
+
item.innerHTML = buildAnnotationItemHtml(annotation);
|
|
2245
|
+
bindAnnotationItemEvents(item, annotation, lineEl, annotationRow);
|
|
2246
|
+
state.staleCounts[state.currentFileId] = Math.max(0, (state.staleCounts[state.currentFileId] || 1) - 1);
|
|
2247
|
+
renderFileList();
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
// Drag handle
|
|
2251
|
+
var handle = item.querySelector('.annotation-drag-handle');
|
|
2252
|
+
if (handle) {
|
|
2253
|
+
handle.addEventListener('dragstart', (e) => {
|
|
2254
|
+
e.stopPropagation();
|
|
2255
|
+
state._dragAnnotation = { id: annotation.id, item: item, annotation: annotation };
|
|
2256
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
2257
|
+
e.dataTransfer.setData('text/plain', annotation.id);
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function showReclassifyPopup(badge, item, annotation) {
|
|
2263
|
+
// Remove any existing popup
|
|
2264
|
+
document.querySelectorAll('.reclassify-popup').forEach(el => el.remove());
|
|
2265
|
+
|
|
2266
|
+
var popup = document.createElement('div');
|
|
2267
|
+
popup.className = 'reclassify-popup';
|
|
2268
|
+
popup.innerHTML = CATEGORIES.map(function(c) {
|
|
2269
|
+
return '<div class="reclassify-option' + (c.value === annotation.category ? ' active' : '') + '" data-value="' + c.value + '">' +
|
|
2270
|
+
'<span class="annotation-category category-' + c.value + '">' + c.label + '</span>' +
|
|
2271
|
+
'</div>';
|
|
2272
|
+
}).join('');
|
|
2273
|
+
|
|
2274
|
+
// Position near the badge
|
|
2275
|
+
var rect = badge.getBoundingClientRect();
|
|
2276
|
+
popup.style.position = 'fixed';
|
|
2277
|
+
popup.style.left = rect.left + 'px';
|
|
2278
|
+
popup.style.top = (rect.bottom + 4) + 'px';
|
|
2279
|
+
popup.style.zIndex = '1000';
|
|
2280
|
+
|
|
2281
|
+
popup.addEventListener('click', async (e) => {
|
|
2282
|
+
var opt = e.target.closest('.reclassify-option');
|
|
2283
|
+
if (!opt) return;
|
|
2284
|
+
e.stopPropagation();
|
|
2285
|
+
var newCategory = opt.dataset.value;
|
|
2286
|
+
if (newCategory === annotation.category) { popup.remove(); return; }
|
|
2287
|
+
annotation.category = newCategory;
|
|
2288
|
+
await api('/annotations/' + annotation.id, { method: 'PATCH', body: { content: annotation.content, category: newCategory } });
|
|
2289
|
+
item.innerHTML = buildAnnotationItemHtml(annotation);
|
|
2290
|
+
var row = item.closest('.annotation-row');
|
|
2291
|
+
var lineElRef = row ? row.previousElementSibling : null;
|
|
2292
|
+
bindAnnotationItemEvents(item, annotation, lineElRef, row);
|
|
2293
|
+
popup.remove();
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
document.body.appendChild(popup);
|
|
2297
|
+
|
|
2298
|
+
// Close on outside click
|
|
2299
|
+
var closePopup = function(e) {
|
|
2300
|
+
if (!popup.contains(e.target)) {
|
|
2301
|
+
popup.remove();
|
|
2302
|
+
document.removeEventListener('click', closePopup, true);
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
setTimeout(function() {
|
|
2306
|
+
document.addEventListener('click', closePopup, true);
|
|
2307
|
+
}, 0);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function editAnnotation(item, annotation) {
|
|
2311
|
+
var annotationRow = item.closest('.annotation-row');
|
|
2312
|
+
var formContainer = document.createElement('div');
|
|
2313
|
+
formContainer.className = 'annotation-form-container';
|
|
2314
|
+
formContainer.innerHTML =
|
|
2315
|
+
'<div class="annotation-form">' +
|
|
2316
|
+
buildCategoryBadge(annotation.category) +
|
|
2317
|
+
'<textarea>' + esc(annotation.content) + '</textarea>' +
|
|
2318
|
+
'<div class="annotation-form-actions">' +
|
|
2319
|
+
'<button class="btn btn-sm cancel-edit">Cancel</button>' +
|
|
2320
|
+
'<button class="btn btn-sm btn-primary save-edit">Save</button>' +
|
|
2321
|
+
'</div>' +
|
|
2322
|
+
'</div>';
|
|
2323
|
+
|
|
2324
|
+
item.style.display = 'none';
|
|
2325
|
+
annotationRow.parentNode.insertBefore(formContainer, annotationRow.nextSibling);
|
|
2326
|
+
|
|
2327
|
+
bindCategoryBadgeClick(formContainer);
|
|
2328
|
+
|
|
2329
|
+
function cancelEdit() {
|
|
2330
|
+
item.style.display = '';
|
|
2331
|
+
formContainer.remove();
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
formContainer.querySelector('.cancel-edit').addEventListener('click', (e) => {
|
|
2335
|
+
e.stopPropagation();
|
|
2336
|
+
cancelEdit();
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
formContainer.querySelector('.save-edit').addEventListener('click', async (e) => {
|
|
2340
|
+
e.stopPropagation();
|
|
2341
|
+
const content = formContainer.querySelector('textarea').value.trim();
|
|
2342
|
+
const category = formContainer.querySelector('.form-category-badge').dataset.category;
|
|
2343
|
+
if (!content) return;
|
|
2344
|
+
annotation.content = content;
|
|
2345
|
+
annotation.category = category;
|
|
2346
|
+
await api('/annotations/' + annotation.id, { method: 'PATCH', body: { content, category } });
|
|
2347
|
+
item.innerHTML = buildAnnotationItemHtml(annotation);
|
|
2348
|
+
rebindAnnotationActions(item, annotation);
|
|
2349
|
+
item.style.display = '';
|
|
2350
|
+
formContainer.remove();
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
const textarea = formContainer.querySelector('textarea');
|
|
2354
|
+
textarea.focus();
|
|
2355
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
2356
|
+
textarea.addEventListener('keydown', (e) => {
|
|
2357
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
2358
|
+
formContainer.querySelector('.save-edit').click();
|
|
2359
|
+
}
|
|
2360
|
+
if (e.key === 'Escape') {
|
|
2361
|
+
cancelEdit();
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function rebindAnnotationActions(item, annotation) {
|
|
2367
|
+
var row = item.closest('.annotation-row');
|
|
2368
|
+
var lineEl = row ? row.previousElementSibling : null;
|
|
2369
|
+
bindAnnotationItemEvents(item, annotation, lineEl, row);
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// --- Drag & Drop for Annotations ---
|
|
2373
|
+
function bindDragDrop() {
|
|
2374
|
+
document.querySelectorAll('.diff-line').forEach(el => {
|
|
2375
|
+
el.addEventListener('dragover', (e) => {
|
|
2376
|
+
if (!state._dragAnnotation) return;
|
|
2377
|
+
e.preventDefault();
|
|
2378
|
+
e.dataTransfer.dropEffect = 'move';
|
|
2379
|
+
document.querySelectorAll('.diff-line.drag-over').forEach(d => d.classList.remove('drag-over'));
|
|
2380
|
+
el.classList.add('drag-over');
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
el.addEventListener('dragleave', () => {
|
|
2384
|
+
el.classList.remove('drag-over');
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
el.addEventListener('drop', async (e) => {
|
|
2388
|
+
e.preventDefault();
|
|
2389
|
+
el.classList.remove('drag-over');
|
|
2390
|
+
document.querySelectorAll('.diff-line.drag-over').forEach(d => d.classList.remove('drag-over'));
|
|
2391
|
+
if (!state._dragAnnotation) return;
|
|
2392
|
+
|
|
2393
|
+
const lineNum = parseInt(el.dataset.line, 10);
|
|
2394
|
+
const side = el.dataset.side || 'new';
|
|
2395
|
+
if (isNaN(lineNum)) return;
|
|
2396
|
+
|
|
2397
|
+
const drag = state._dragAnnotation;
|
|
2398
|
+
state._dragAnnotation = null;
|
|
2399
|
+
|
|
2400
|
+
await api('/annotations/' + drag.id + '/move', {
|
|
2401
|
+
method: 'PATCH',
|
|
2402
|
+
body: { lineNumber: lineNum, side: side },
|
|
2403
|
+
});
|
|
2404
|
+
|
|
2405
|
+
// Refresh the file view
|
|
2406
|
+
if (state.currentFileId) selectFile(state.currentFileId);
|
|
2407
|
+
});
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function bindServerAnnotations() {
|
|
2412
|
+
document.querySelectorAll('.annotation-item').forEach(item => {
|
|
2413
|
+
const id = item.dataset.annotationId;
|
|
2414
|
+
if (!id) return;
|
|
2415
|
+
const isStale = item.dataset.isStale === 'true';
|
|
2416
|
+
const category = item.querySelector('.annotation-category')?.textContent || '';
|
|
2417
|
+
const content = item.querySelector('.annotation-text')?.textContent || '';
|
|
2418
|
+
const annotation = { id: id, category: category, content: content, is_stale: isStale };
|
|
2419
|
+
var row = item.closest('.annotation-row');
|
|
2420
|
+
var lineEl = row ? row.previousElementSibling : null;
|
|
2421
|
+
bindAnnotationItemEvents(item, annotation, lineEl, row);
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// --- Diff Mode ---
|
|
2426
|
+
function bindDiffModeToggle() {
|
|
2427
|
+
document.querySelectorAll('[data-diff-mode]').forEach(btn => {
|
|
2428
|
+
btn.addEventListener('click', () => {
|
|
2429
|
+
state.diffMode = btn.dataset.diffMode;
|
|
2430
|
+
document.querySelectorAll('[data-diff-mode]').forEach(b => b.classList.toggle('active', b === btn));
|
|
2431
|
+
if (state.currentFileId) selectFile(state.currentFileId);
|
|
2432
|
+
});
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// --- File Filter ---
|
|
2437
|
+
function bindFileFilter() {
|
|
2438
|
+
var input = document.getElementById('file-filter');
|
|
2439
|
+
if (!input) return;
|
|
2440
|
+
var timer = null;
|
|
2441
|
+
input.addEventListener('input', function() {
|
|
2442
|
+
clearTimeout(timer);
|
|
2443
|
+
timer = setTimeout(function() {
|
|
2444
|
+
state.filterText = input.value;
|
|
2445
|
+
renderFileList();
|
|
2446
|
+
}, 150);
|
|
2447
|
+
});
|
|
2448
|
+
// Also handle Escape to clear
|
|
2449
|
+
input.addEventListener('keydown', function(e) {
|
|
2450
|
+
if (e.key === 'Escape') {
|
|
2451
|
+
input.value = '';
|
|
2452
|
+
state.filterText = '';
|
|
2453
|
+
renderFileList();
|
|
2454
|
+
input.blur();
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// --- Sidebar Resize ---
|
|
2460
|
+
function bindSidebarResize() {
|
|
2461
|
+
var handle = document.getElementById('sidebar-resize');
|
|
2462
|
+
var sidebar = document.querySelector('.sidebar');
|
|
2463
|
+
if (!handle || !sidebar) return;
|
|
2464
|
+
|
|
2465
|
+
var dragging = false;
|
|
2466
|
+
var startX, startWidth;
|
|
2467
|
+
|
|
2468
|
+
handle.addEventListener('mousedown', function(e) {
|
|
2469
|
+
dragging = true;
|
|
2470
|
+
startX = e.clientX;
|
|
2471
|
+
startWidth = sidebar.offsetWidth;
|
|
2472
|
+
handle.classList.add('dragging');
|
|
2473
|
+
document.body.style.cursor = 'col-resize';
|
|
2474
|
+
document.body.style.userSelect = 'none';
|
|
2475
|
+
e.preventDefault();
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
document.addEventListener('mousemove', function(e) {
|
|
2479
|
+
if (!dragging) return;
|
|
2480
|
+
var newWidth = startWidth + (e.clientX - startX);
|
|
2481
|
+
newWidth = Math.max(200, Math.min(newWidth, window.innerWidth * 0.6));
|
|
2482
|
+
sidebar.style.width = newWidth + 'px';
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
document.addEventListener('mouseup', function() {
|
|
2486
|
+
if (!dragging) return;
|
|
2487
|
+
dragging = false;
|
|
2488
|
+
handle.classList.remove('dragging');
|
|
2489
|
+
document.body.style.cursor = '';
|
|
2490
|
+
document.body.style.userSelect = '';
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// --- Wrap Toggle ---
|
|
2495
|
+
function bindWrapToggle() {
|
|
2496
|
+
const btn = document.getElementById('wrap-toggle');
|
|
2497
|
+
if (!btn) return;
|
|
2498
|
+
btn.addEventListener('click', () => {
|
|
2499
|
+
state.wrapLines = !state.wrapLines;
|
|
2500
|
+
btn.classList.toggle('active', state.wrapLines);
|
|
2501
|
+
const container = document.getElementById('diff-container');
|
|
2502
|
+
if (container) {
|
|
2503
|
+
container.classList.toggle('wrap-lines', state.wrapLines);
|
|
2504
|
+
}
|
|
2505
|
+
// Reset scroll positions when toggling
|
|
2506
|
+
if (!state.wrapLines) {
|
|
2507
|
+
resetScrollSync();
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// --- Scroll Sync (split mode, no-wrap) ---
|
|
2513
|
+
function initScrollSync() {
|
|
2514
|
+
const container = document.getElementById('diff-container');
|
|
2515
|
+
if (!container) return;
|
|
2516
|
+
|
|
2517
|
+
let lastScrollLeft = 0;
|
|
2518
|
+
let rafId = null;
|
|
2519
|
+
let syncing = false;
|
|
2520
|
+
|
|
2521
|
+
container.addEventListener('scroll', function(e) {
|
|
2522
|
+
if (syncing || state.wrapLines || state.diffMode !== 'split') return;
|
|
2523
|
+
const target = e.target;
|
|
2524
|
+
if (!target.classList || !target.classList.contains('code')) return;
|
|
2525
|
+
if (!target.closest('.split-row')) return;
|
|
2526
|
+
|
|
2527
|
+
const scrollLeft = target.scrollLeft;
|
|
2528
|
+
if (scrollLeft === lastScrollLeft) return;
|
|
2529
|
+
lastScrollLeft = scrollLeft;
|
|
2530
|
+
|
|
2531
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
2532
|
+
rafId = requestAnimationFrame(function() {
|
|
2533
|
+
syncing = true;
|
|
2534
|
+
container.querySelectorAll('.split-row .code').forEach(function(el) {
|
|
2535
|
+
if (el !== target && el.scrollLeft !== scrollLeft) {
|
|
2536
|
+
el.scrollLeft = scrollLeft;
|
|
2537
|
+
}
|
|
2538
|
+
});
|
|
2539
|
+
syncing = false;
|
|
2540
|
+
});
|
|
2541
|
+
}, true);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function resetScrollSync() {
|
|
2545
|
+
const container = document.getElementById('diff-container');
|
|
2546
|
+
if (!container) return;
|
|
2547
|
+
container.querySelectorAll('.split-row .code').forEach(function(el) {
|
|
2548
|
+
el.scrollLeft = 0;
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// --- Complete ---
|
|
2553
|
+
function bindCompleteButton() {
|
|
2554
|
+
const btn = document.getElementById('complete-review');
|
|
2555
|
+
if (!btn) return;
|
|
2556
|
+
btn.addEventListener('click', () => {
|
|
2557
|
+
showCompleteModal();
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function showCompleteModal() {
|
|
2562
|
+
var totalStale = 0;
|
|
2563
|
+
Object.keys(state.staleCounts).forEach(function(k) { totalStale += (state.staleCounts[k] || 0); });
|
|
2564
|
+
|
|
2565
|
+
const overlay = document.createElement('div');
|
|
2566
|
+
overlay.className = 'modal-overlay';
|
|
2567
|
+
|
|
2568
|
+
if (totalStale > 0) {
|
|
2569
|
+
overlay.innerHTML =
|
|
2570
|
+
'<div class="modal">' +
|
|
2571
|
+
'<h3>Stale Annotations</h3>' +
|
|
2572
|
+
'<p>There ' + (totalStale === 1 ? 'is 1 stale annotation' : 'are ' + totalStale + ' stale annotations') +
|
|
2573
|
+
' that could not be matched to the current diff. What would you like to do?</p>' +
|
|
2574
|
+
'<div class="modal-actions">' +
|
|
2575
|
+
'<button class="btn btn-sm modal-cancel">Cancel</button>' +
|
|
2576
|
+
'<button class="btn btn-sm btn-danger" data-stale-action="discard">Discard All Stale</button>' +
|
|
2577
|
+
'<button class="btn btn-sm btn-primary" data-stale-action="keep">Keep All & Complete</button>' +
|
|
2578
|
+
'</div>' +
|
|
2579
|
+
'</div>';
|
|
2580
|
+
|
|
2581
|
+
overlay.querySelector('.modal-cancel').addEventListener('click', () => overlay.remove());
|
|
2582
|
+
overlay.querySelector('[data-stale-action="discard"]').addEventListener('click', async () => {
|
|
2583
|
+
await api('/annotations/stale/delete-all', { method: 'POST' });
|
|
2584
|
+
state.staleCounts = {};
|
|
2585
|
+
renderFileList();
|
|
2586
|
+
overlay.remove();
|
|
2587
|
+
showCompleteModal();
|
|
2588
|
+
});
|
|
2589
|
+
overlay.querySelector('[data-stale-action="keep"]').addEventListener('click', async () => {
|
|
2590
|
+
await api('/annotations/stale/keep-all', { method: 'POST' });
|
|
2591
|
+
state.staleCounts = {};
|
|
2592
|
+
renderFileList();
|
|
2593
|
+
overlay.remove();
|
|
2594
|
+
showCompleteModal();
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
2598
|
+
document.body.appendChild(overlay);
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
overlay.innerHTML =
|
|
2603
|
+
'<div class="modal">' +
|
|
2604
|
+
'<h3>Complete Review</h3>' +
|
|
2605
|
+
'<p>This will generate a review summary that AI tools can read and act on. Annotations will be exported to .glassbox/ in the repository.</p>' +
|
|
2606
|
+
'<div class="modal-actions">' +
|
|
2607
|
+
'<button class="btn btn-sm modal-cancel">Cancel</button>' +
|
|
2608
|
+
'<button class="btn btn-sm btn-primary modal-confirm">Complete</button>' +
|
|
2609
|
+
'</div>' +
|
|
2610
|
+
'</div>';
|
|
2611
|
+
|
|
2612
|
+
overlay.querySelector('.modal-cancel').addEventListener('click', () => overlay.remove());
|
|
2613
|
+
overlay.querySelector('.modal-confirm').addEventListener('click', async () => {
|
|
2614
|
+
const result = await api('/review/complete', { method: 'POST' });
|
|
2615
|
+
var aiCommand = result.isCurrent
|
|
2616
|
+
? 'Read .glassbox/latest-review.md and apply the feedback.'
|
|
2617
|
+
: 'Read .glassbox/review-' + result.reviewId + '.md and apply the feedback.';
|
|
2618
|
+
|
|
2619
|
+
var gitignoreHtml = '';
|
|
2620
|
+
if (result.gitignorePrompt) {
|
|
2621
|
+
gitignoreHtml =
|
|
2622
|
+
'<div class="modal-gitignore">' +
|
|
2623
|
+
'<p class="modal-label">.glassbox/ is not in your .gitignore</p>' +
|
|
2624
|
+
'<div class="modal-actions" style="justify-content:flex-start;margin-top:4px">' +
|
|
2625
|
+
'<button class="btn btn-sm btn-primary" id="gitignore-add">Add to .gitignore</button>' +
|
|
2626
|
+
'<button class="btn btn-sm" id="gitignore-dismiss">Don\\'t ask for 30 days</button>' +
|
|
2627
|
+
'</div>' +
|
|
2628
|
+
'</div>';
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
overlay.querySelector('.modal').innerHTML =
|
|
2632
|
+
'<h3>Review Completed</h3>' +
|
|
2633
|
+
'<p class="modal-label">Review exported to:</p>' +
|
|
2634
|
+
'<div class="modal-copyable" data-copy="' + esc(result.exportPath) + '" title="Click to copy">' + esc(result.exportPath) + '</div>' +
|
|
2635
|
+
'<p class="modal-label">Tell your AI tool:</p>' +
|
|
2636
|
+
'<div class="modal-copyable" data-copy="' + esc(aiCommand) + '" title="Click to copy">' + esc(aiCommand) + '</div>' +
|
|
2637
|
+
gitignoreHtml +
|
|
2638
|
+
'<div class="modal-actions"><button class="btn btn-sm btn-primary" onclick="this.closest(\\'.modal-overlay\\').remove()">Done</button></div>';
|
|
2639
|
+
|
|
2640
|
+
// Bind copy-to-clipboard
|
|
2641
|
+
overlay.querySelectorAll('.modal-copyable').forEach(function(el) {
|
|
2642
|
+
el.addEventListener('click', function() {
|
|
2643
|
+
navigator.clipboard.writeText(el.dataset.copy);
|
|
2644
|
+
el.classList.add('copied');
|
|
2645
|
+
setTimeout(function() { el.classList.remove('copied'); }, 1500);
|
|
2646
|
+
});
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
// Bind gitignore buttons
|
|
2650
|
+
var addBtn = overlay.querySelector('#gitignore-add');
|
|
2651
|
+
if (addBtn) {
|
|
2652
|
+
addBtn.addEventListener('click', async function() {
|
|
2653
|
+
await api('/gitignore/add', { method: 'POST' });
|
|
2654
|
+
var container = addBtn.closest('.modal-gitignore');
|
|
2655
|
+
container.innerHTML = '<p class="modal-label" style="color:var(--green)">Added .glassbox/ to .gitignore</p>';
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
var dismissBtn = overlay.querySelector('#gitignore-dismiss');
|
|
2659
|
+
if (dismissBtn) {
|
|
2660
|
+
dismissBtn.addEventListener('click', async function() {
|
|
2661
|
+
await api('/gitignore/dismiss', { method: 'POST' });
|
|
2662
|
+
dismissBtn.closest('.modal-gitignore').remove();
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// Swap complete button to reopen
|
|
2667
|
+
const completeBtn = document.getElementById('complete-review');
|
|
2668
|
+
if (completeBtn) {
|
|
2669
|
+
const reopenBtn = document.createElement('button');
|
|
2670
|
+
reopenBtn.className = 'btn btn-primary';
|
|
2671
|
+
reopenBtn.id = 'reopen-review';
|
|
2672
|
+
reopenBtn.textContent = 'Reopen Review';
|
|
2673
|
+
completeBtn.replaceWith(reopenBtn);
|
|
2674
|
+
bindReopenButton();
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
2679
|
+
document.body.appendChild(overlay);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
function bindReopenButton() {
|
|
2683
|
+
const btn = document.getElementById('reopen-review');
|
|
2684
|
+
if (!btn) return;
|
|
2685
|
+
btn.addEventListener('click', async () => {
|
|
2686
|
+
await api('/review/reopen', { method: 'POST' });
|
|
2687
|
+
// Swap reopen button to complete
|
|
2688
|
+
const completeBtn = document.createElement('button');
|
|
2689
|
+
completeBtn.className = 'btn btn-primary btn-complete';
|
|
2690
|
+
completeBtn.id = 'complete-review';
|
|
2691
|
+
completeBtn.textContent = 'Complete Review';
|
|
2692
|
+
btn.replaceWith(completeBtn);
|
|
2693
|
+
bindCompleteButton();
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// --- Progress ---
|
|
2698
|
+
function updateProgress() {
|
|
2699
|
+
const total = state.files.length;
|
|
2700
|
+
const reviewed = state.files.filter(f => f.status === 'reviewed').length;
|
|
2701
|
+
const summary = document.getElementById('progress-summary');
|
|
2702
|
+
if (summary) summary.textContent = reviewed + ' of ' + total + ' files reviewed';
|
|
2703
|
+
|
|
2704
|
+
// Update progress bar
|
|
2705
|
+
let bar = document.querySelector('.progress-bar');
|
|
2706
|
+
if (!bar) {
|
|
2707
|
+
bar = document.createElement('div');
|
|
2708
|
+
bar.className = 'progress-bar';
|
|
2709
|
+
bar.innerHTML = '<div class="progress-bar-fill"></div>';
|
|
2710
|
+
const controls = document.querySelector('.sidebar-controls');
|
|
2711
|
+
if (controls) controls.appendChild(bar);
|
|
2712
|
+
}
|
|
2713
|
+
const fill = bar.querySelector('.progress-bar-fill');
|
|
2714
|
+
if (fill) fill.style.width = (total ? (reviewed / total * 100) : 0) + '%';
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// --- Sidebar Events ---
|
|
2718
|
+
function bindSidebarEvents() {
|
|
2719
|
+
// Keyboard navigation
|
|
2720
|
+
document.addEventListener('keydown', (e) => {
|
|
2721
|
+
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
|
|
2722
|
+
if (e.key === 'j' || e.key === 'ArrowDown') {
|
|
2723
|
+
navigateFile(1);
|
|
2724
|
+
e.preventDefault();
|
|
2725
|
+
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
|
2726
|
+
navigateFile(-1);
|
|
2727
|
+
e.preventDefault();
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function navigateFile(delta) {
|
|
2733
|
+
var order = state.fileOrder;
|
|
2734
|
+
var idx = order.indexOf(state.currentFileId);
|
|
2735
|
+
var next = idx + delta;
|
|
2736
|
+
if (next >= 0 && next < order.length) {
|
|
2737
|
+
selectFile(order[next]);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// Run
|
|
2742
|
+
init();
|
|
2743
|
+
})();
|
|
2744
|
+
`;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// src/components/fileList.tsx
|
|
2748
|
+
function buildFileTree(files) {
|
|
2749
|
+
const root = { name: "", children: [], files: [] };
|
|
2750
|
+
for (const f of files) {
|
|
2751
|
+
const parts = f.file_path.split("/");
|
|
2752
|
+
let node = root;
|
|
2753
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2754
|
+
let child = node.children.find((c) => c.name === parts[i]);
|
|
2755
|
+
if (!child) {
|
|
2756
|
+
child = { name: parts[i], children: [], files: [] };
|
|
2757
|
+
node.children.push(child);
|
|
2758
|
+
}
|
|
2759
|
+
node = child;
|
|
2760
|
+
}
|
|
2761
|
+
node.files.push(f);
|
|
2762
|
+
}
|
|
2763
|
+
compressTree(root);
|
|
2764
|
+
return root;
|
|
2765
|
+
}
|
|
2766
|
+
function compressTree(node) {
|
|
2767
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
2768
|
+
let child = node.children[i];
|
|
2769
|
+
while (child.children.length === 1 && child.files.length === 0) {
|
|
2770
|
+
const grandchild = child.children[0];
|
|
2771
|
+
child = { name: child.name + "/" + grandchild.name, children: grandchild.children, files: grandchild.files };
|
|
2772
|
+
node.children[i] = child;
|
|
2773
|
+
}
|
|
2774
|
+
compressTree(child);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
function countFiles(node) {
|
|
2778
|
+
let count = node.files.length;
|
|
2779
|
+
for (const child of node.children) count += countFiles(child);
|
|
2780
|
+
return count;
|
|
2781
|
+
}
|
|
2782
|
+
function hasStale(node, staleCounts) {
|
|
2783
|
+
for (const f of node.files) {
|
|
2784
|
+
if (staleCounts[f.id]) return true;
|
|
2785
|
+
}
|
|
2786
|
+
for (const child of node.children) {
|
|
2787
|
+
if (hasStale(child, staleCounts)) return true;
|
|
2788
|
+
}
|
|
2789
|
+
return false;
|
|
2790
|
+
}
|
|
2791
|
+
function TreeView({ node, depth, annotationCounts, staleCounts }) {
|
|
2792
|
+
const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name));
|
|
2793
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
2794
|
+
sortedChildren.map((child) => {
|
|
2795
|
+
const total = countFiles(child);
|
|
2796
|
+
const isCollapsible = total > 1;
|
|
2797
|
+
const stale = hasStale(child, staleCounts);
|
|
2798
|
+
return /* @__PURE__ */ jsx("div", { className: "folder-group", children: [
|
|
2799
|
+
/* @__PURE__ */ jsx("div", { className: `folder-header${isCollapsible ? " collapsible" : ""}`, style: `padding-left:${16 + depth * 12}px`, children: [
|
|
2800
|
+
isCollapsible ? /* @__PURE__ */ jsx("span", { className: "folder-arrow", children: "\u25BE" }) : /* @__PURE__ */ jsx("span", { className: "folder-arrow-spacer" }),
|
|
2801
|
+
/* @__PURE__ */ jsx("span", { className: "folder-name", children: [
|
|
2802
|
+
child.name,
|
|
2803
|
+
"/"
|
|
2804
|
+
] }),
|
|
2805
|
+
stale ? /* @__PURE__ */ jsx("span", { className: "stale-dot" }) : null
|
|
2806
|
+
] }),
|
|
2807
|
+
/* @__PURE__ */ jsx("div", { className: "folder-content", children: /* @__PURE__ */ jsx(TreeView, { node: child, depth: depth + 1, annotationCounts, staleCounts }) })
|
|
2808
|
+
] });
|
|
2809
|
+
}),
|
|
2810
|
+
node.files.map((f) => {
|
|
2811
|
+
const diff = JSON.parse(f.diff_data || "{}");
|
|
2812
|
+
const count = annotationCounts[f.id] || 0;
|
|
2813
|
+
const stale = staleCounts[f.id] || 0;
|
|
2814
|
+
const fileName = f.file_path.split("/").pop();
|
|
2815
|
+
return /* @__PURE__ */ jsx("div", { className: "file-item", "data-file-id": f.id, style: `padding-left:${16 + depth * 12}px`, children: [
|
|
2816
|
+
/* @__PURE__ */ jsx("span", { className: `status-dot ${f.status}` }),
|
|
2817
|
+
/* @__PURE__ */ jsx("span", { className: "file-name", title: f.file_path, children: fileName }),
|
|
2818
|
+
/* @__PURE__ */ jsx("span", { className: `file-status ${diff.status || ""}`, children: diff.status || "" }),
|
|
2819
|
+
stale > 0 ? /* @__PURE__ */ jsx("span", { className: "stale-dot" }) : null,
|
|
2820
|
+
count > 0 ? /* @__PURE__ */ jsx("span", { className: "annotation-count", children: count }) : null
|
|
2821
|
+
] });
|
|
2822
|
+
})
|
|
2823
|
+
] });
|
|
2824
|
+
}
|
|
2825
|
+
function FileList({ files, annotationCounts, staleCounts }) {
|
|
2826
|
+
const tree = buildFileTree(files);
|
|
2827
|
+
return /* @__PURE__ */ jsx("div", { className: "file-list", children: /* @__PURE__ */ jsx("div", { className: "file-list-items", children: /* @__PURE__ */ jsx(TreeView, { node: tree, depth: 0, annotationCounts, staleCounts }) }) });
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// src/components/diffView.tsx
|
|
2831
|
+
function DiffView({ file, diff, annotations, mode }) {
|
|
2832
|
+
const annotationsByLine = {};
|
|
2833
|
+
for (const a of annotations) {
|
|
2834
|
+
const key = `${a.line_number}:${a.side}`;
|
|
2835
|
+
if (!annotationsByLine[key]) annotationsByLine[key] = [];
|
|
2836
|
+
annotationsByLine[key].push(a);
|
|
2837
|
+
}
|
|
2838
|
+
return /* @__PURE__ */ jsx("div", { className: "diff-view", "data-file-id": file.id, "data-file-path": file.file_path, children: [
|
|
2839
|
+
/* @__PURE__ */ jsx("div", { className: "diff-header", children: [
|
|
2840
|
+
/* @__PURE__ */ jsx("span", { className: "file-path", children: diff.filePath }),
|
|
2841
|
+
/* @__PURE__ */ jsx("div", { className: "diff-header-actions", children: /* @__PURE__ */ jsx("span", { className: `file-status ${diff.status}`, children: diff.status }) })
|
|
2842
|
+
] }),
|
|
2843
|
+
diff.isBinary ? /* @__PURE__ */ jsx("div", { className: "hunk-separator", children: "Binary file" }) : diff.status === "added" || diff.status === "deleted" || mode === "unified" ? /* @__PURE__ */ jsx(UnifiedDiff, { hunks: diff.hunks, annotationsByLine }) : /* @__PURE__ */ jsx(SplitDiff, { hunks: diff.hunks, annotationsByLine })
|
|
2844
|
+
] });
|
|
2845
|
+
}
|
|
2846
|
+
function SplitDiff({ hunks, annotationsByLine }) {
|
|
2847
|
+
return /* @__PURE__ */ jsx("div", { className: "diff-table-split", children: hunks.map((hunk, hunkIdx) => {
|
|
2848
|
+
const pairs = pairLines(hunk.lines);
|
|
2849
|
+
return /* @__PURE__ */ jsx("div", { className: "hunk-block", children: [
|
|
2850
|
+
/* @__PURE__ */ jsx(
|
|
2851
|
+
"div",
|
|
2852
|
+
{
|
|
2853
|
+
className: "hunk-separator",
|
|
2854
|
+
"data-hunk-idx": hunkIdx,
|
|
2855
|
+
"data-old-start": hunk.oldStart,
|
|
2856
|
+
"data-old-count": hunk.oldCount,
|
|
2857
|
+
"data-new-start": hunk.newStart,
|
|
2858
|
+
"data-new-count": hunk.newCount,
|
|
2859
|
+
children: [
|
|
2860
|
+
"@@ -",
|
|
2861
|
+
hunk.oldStart,
|
|
2862
|
+
",",
|
|
2863
|
+
hunk.oldCount,
|
|
2864
|
+
" +",
|
|
2865
|
+
hunk.newStart,
|
|
2866
|
+
",",
|
|
2867
|
+
hunk.newCount,
|
|
2868
|
+
" @@"
|
|
2869
|
+
]
|
|
2870
|
+
}
|
|
2871
|
+
),
|
|
2872
|
+
pairs.map((pair) => {
|
|
2873
|
+
const leftAnns = pair.left ? annotationsByLine[`${pair.left.oldNum}:old`] || [] : [];
|
|
2874
|
+
const rightAnns = pair.right ? annotationsByLine[`${pair.right.newNum}:new`] || [] : [];
|
|
2875
|
+
const allAnns = [...leftAnns, ...rightAnns];
|
|
2876
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
2877
|
+
/* @__PURE__ */ jsx("div", { className: "split-row", children: [
|
|
2878
|
+
/* @__PURE__ */ jsx(
|
|
2879
|
+
"div",
|
|
2880
|
+
{
|
|
2881
|
+
className: `diff-line split-left ${pair.left?.type || "empty"}`,
|
|
2882
|
+
"data-line": pair.left?.oldNum ?? "",
|
|
2883
|
+
"data-side": "old",
|
|
2884
|
+
children: [
|
|
2885
|
+
/* @__PURE__ */ jsx("span", { className: "gutter", children: pair.left?.oldNum ?? "" }),
|
|
2886
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: pair.left ? raw(escapeHtml(pair.left.content)) : "" })
|
|
2887
|
+
]
|
|
2888
|
+
}
|
|
2889
|
+
),
|
|
2890
|
+
/* @__PURE__ */ jsx(
|
|
2891
|
+
"div",
|
|
2892
|
+
{
|
|
2893
|
+
className: `diff-line split-right ${pair.right?.type || "empty"}`,
|
|
2894
|
+
"data-line": pair.right?.newNum ?? "",
|
|
2895
|
+
"data-side": "new",
|
|
2896
|
+
children: [
|
|
2897
|
+
/* @__PURE__ */ jsx("span", { className: "gutter", children: pair.right?.newNum ?? "" }),
|
|
2898
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: pair.right ? raw(escapeHtml(pair.right.content)) : "" })
|
|
2899
|
+
]
|
|
2900
|
+
}
|
|
2901
|
+
)
|
|
2902
|
+
] }),
|
|
2903
|
+
allAnns.length > 0 ? /* @__PURE__ */ jsx(AnnotationRows, { annotations: allAnns }) : null
|
|
2904
|
+
] });
|
|
2905
|
+
})
|
|
2906
|
+
] });
|
|
2907
|
+
}) });
|
|
2908
|
+
}
|
|
2909
|
+
function pairLines(lines) {
|
|
2910
|
+
const pairs = [];
|
|
2911
|
+
let i = 0;
|
|
2912
|
+
while (i < lines.length) {
|
|
2913
|
+
const line = lines[i];
|
|
2914
|
+
if (line.type === "context") {
|
|
2915
|
+
pairs.push({ left: line, right: line });
|
|
2916
|
+
i++;
|
|
2917
|
+
} else if (line.type === "remove") {
|
|
2918
|
+
const removes = [];
|
|
2919
|
+
while (i < lines.length && lines[i].type === "remove") {
|
|
2920
|
+
removes.push(lines[i]);
|
|
2921
|
+
i++;
|
|
2922
|
+
}
|
|
2923
|
+
const adds = [];
|
|
2924
|
+
while (i < lines.length && lines[i].type === "add") {
|
|
2925
|
+
adds.push(lines[i]);
|
|
2926
|
+
i++;
|
|
2927
|
+
}
|
|
2928
|
+
const max = Math.max(removes.length, adds.length);
|
|
2929
|
+
for (let j = 0; j < max; j++) {
|
|
2930
|
+
pairs.push({
|
|
2931
|
+
left: j < removes.length ? removes[j] : null,
|
|
2932
|
+
right: j < adds.length ? adds[j] : null
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
} else if (line.type === "add") {
|
|
2936
|
+
pairs.push({ left: null, right: line });
|
|
2937
|
+
i++;
|
|
2938
|
+
} else {
|
|
2939
|
+
i++;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
return pairs;
|
|
2943
|
+
}
|
|
2944
|
+
function UnifiedDiff({ hunks, annotationsByLine }) {
|
|
2945
|
+
return /* @__PURE__ */ jsx("div", { className: "diff-table-unified", children: hunks.map((hunk, hunkIdx) => /* @__PURE__ */ jsx("div", { className: "hunk-block", children: [
|
|
2946
|
+
/* @__PURE__ */ jsx(
|
|
2947
|
+
"div",
|
|
2948
|
+
{
|
|
2949
|
+
className: "hunk-separator",
|
|
2950
|
+
"data-hunk-idx": hunkIdx,
|
|
2951
|
+
"data-old-start": hunk.oldStart,
|
|
2952
|
+
"data-old-count": hunk.oldCount,
|
|
2953
|
+
"data-new-start": hunk.newStart,
|
|
2954
|
+
"data-new-count": hunk.newCount,
|
|
2955
|
+
children: [
|
|
2956
|
+
"@@ -",
|
|
2957
|
+
hunk.oldStart,
|
|
2958
|
+
",",
|
|
2959
|
+
hunk.oldCount,
|
|
2960
|
+
" +",
|
|
2961
|
+
hunk.newStart,
|
|
2962
|
+
",",
|
|
2963
|
+
hunk.newCount,
|
|
2964
|
+
" @@"
|
|
2965
|
+
]
|
|
2966
|
+
}
|
|
2967
|
+
),
|
|
2968
|
+
hunk.lines.map((line) => {
|
|
2969
|
+
const lineNum = line.type === "remove" ? line.oldNum : line.newNum;
|
|
2970
|
+
const side = line.type === "remove" ? "old" : "new";
|
|
2971
|
+
const anns = annotationsByLine[`${lineNum}:${side}`] || [];
|
|
2972
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
2973
|
+
/* @__PURE__ */ jsx(
|
|
2974
|
+
"div",
|
|
2975
|
+
{
|
|
2976
|
+
className: `diff-line ${line.type}${anns.length ? " has-annotation" : ""}`,
|
|
2977
|
+
"data-line": lineNum,
|
|
2978
|
+
"data-side": side,
|
|
2979
|
+
children: [
|
|
2980
|
+
/* @__PURE__ */ jsx("span", { className: "gutter-old", children: line.oldNum ?? "" }),
|
|
2981
|
+
/* @__PURE__ */ jsx("span", { className: "gutter-new", children: line.newNum ?? "" }),
|
|
2982
|
+
/* @__PURE__ */ jsx("span", { className: "code", children: raw(escapeHtml(line.content)) })
|
|
2983
|
+
]
|
|
2984
|
+
}
|
|
2985
|
+
),
|
|
2986
|
+
anns.length > 0 ? /* @__PURE__ */ jsx(AnnotationRows, { annotations: anns }) : null
|
|
2987
|
+
] });
|
|
2988
|
+
})
|
|
2989
|
+
] })) });
|
|
2990
|
+
}
|
|
2991
|
+
function AnnotationRows({ annotations }) {
|
|
2992
|
+
return /* @__PURE__ */ jsx("div", { className: "annotation-row", children: annotations.map((a) => /* @__PURE__ */ jsx(
|
|
2993
|
+
"div",
|
|
2994
|
+
{
|
|
2995
|
+
className: `annotation-item${a.is_stale ? " annotation-stale" : ""}`,
|
|
2996
|
+
"data-annotation-id": a.id,
|
|
2997
|
+
"data-is-stale": a.is_stale ? "true" : void 0,
|
|
2998
|
+
children: [
|
|
2999
|
+
/* @__PURE__ */ jsx("span", { className: "annotation-drag-handle", draggable: "true", title: "Drag to move", children: "\u283F" }),
|
|
3000
|
+
/* @__PURE__ */ jsx("span", { className: `annotation-category category-${a.category}`, "data-action": "reclassify", children: a.category }),
|
|
3001
|
+
/* @__PURE__ */ jsx("span", { className: "annotation-text", children: a.content }),
|
|
3002
|
+
/* @__PURE__ */ jsx("div", { className: "annotation-actions", children: [
|
|
3003
|
+
a.is_stale ? /* @__PURE__ */ jsx("button", { className: "btn btn-xs btn-keep", "data-action": "keep", children: "Keep" }) : null,
|
|
3004
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-xs btn-icon", "data-action": "edit", title: "Edit", children: raw('<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>') }),
|
|
3005
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-xs btn-icon btn-danger", "data-action": "delete", title: "Delete", children: raw('<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>') })
|
|
3006
|
+
] })
|
|
3007
|
+
]
|
|
3008
|
+
}
|
|
3009
|
+
)) });
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// src/components/reviewHistory.tsx
|
|
3013
|
+
function titleCase(s) {
|
|
3014
|
+
return s.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
3015
|
+
}
|
|
3016
|
+
var trashIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
3017
|
+
function shortenArgs(args) {
|
|
3018
|
+
const shaPattern = /\b([0-9a-f]{7,40})\b/gi;
|
|
3019
|
+
let hasLong = false;
|
|
3020
|
+
const short = args.replace(shaPattern, (match) => {
|
|
3021
|
+
if (match.length > 8) {
|
|
3022
|
+
hasLong = true;
|
|
3023
|
+
return match.slice(0, 7);
|
|
3024
|
+
}
|
|
3025
|
+
return match;
|
|
3026
|
+
});
|
|
3027
|
+
return { short, full: hasLong ? args : "" };
|
|
3028
|
+
}
|
|
3029
|
+
function ReviewHistory({ reviews, currentReviewId }) {
|
|
3030
|
+
const hasOtherReviews = reviews.some((r) => r.id !== currentReviewId);
|
|
3031
|
+
const hasCompletedOthers = reviews.some((r) => r.id !== currentReviewId && r.status === "completed");
|
|
3032
|
+
return /* @__PURE__ */ jsx("div", { className: "history-page", children: [
|
|
3033
|
+
/* @__PURE__ */ jsx("h1", { children: "Review History" }),
|
|
3034
|
+
reviews.length === 0 ? /* @__PURE__ */ jsx("p", { style: "color:var(--text-dim)", children: "No previous reviews found." }) : /* @__PURE__ */ jsx("div", { children: reviews.map((r) => {
|
|
3035
|
+
const isCurrent = r.id === currentReviewId;
|
|
3036
|
+
const href = isCurrent ? "/" : `/review/${r.id}`;
|
|
3037
|
+
let argsDisplay = null;
|
|
3038
|
+
if (r.mode_args) {
|
|
3039
|
+
const { short, full } = shortenArgs(r.mode_args);
|
|
3040
|
+
argsDisplay = full ? /* @__PURE__ */ jsx("span", { title: full, children: [
|
|
3041
|
+
": ",
|
|
3042
|
+
short
|
|
3043
|
+
] }) : /* @__PURE__ */ jsx("span", { children: [
|
|
3044
|
+
": ",
|
|
3045
|
+
short
|
|
3046
|
+
] });
|
|
3047
|
+
}
|
|
3048
|
+
return /* @__PURE__ */ jsx("div", { children: [
|
|
3049
|
+
/* @__PURE__ */ jsx("a", { href, className: "history-item-link", children: /* @__PURE__ */ jsx("div", { className: "history-item", "data-review-id": r.id, children: [
|
|
3050
|
+
/* @__PURE__ */ jsx("h3", { children: [
|
|
3051
|
+
r.repo_name,
|
|
3052
|
+
" - ",
|
|
3053
|
+
titleCase(r.mode),
|
|
3054
|
+
argsDisplay,
|
|
3055
|
+
isCurrent ? /* @__PURE__ */ jsx("span", { className: "status-badge in_progress", style: "margin-left:8px", children: "Current" }) : null,
|
|
3056
|
+
/* @__PURE__ */ jsx("span", { className: `status-badge ${r.status}`, style: "margin-left:8px", children: titleCase(r.status) })
|
|
3057
|
+
] }),
|
|
3058
|
+
/* @__PURE__ */ jsx("div", { className: "meta", children: [
|
|
3059
|
+
"ID: ",
|
|
3060
|
+
r.id,
|
|
3061
|
+
" | Created: ",
|
|
3062
|
+
r.created_at
|
|
3063
|
+
] }),
|
|
3064
|
+
!isCurrent ? /* @__PURE__ */ jsx("button", { className: "delete-review-btn", "data-delete-id": r.id, title: "Delete review", children: raw(trashIcon) }) : null
|
|
3065
|
+
] }) }),
|
|
3066
|
+
isCurrent && hasOtherReviews ? /* @__PURE__ */ jsx("div", { className: "bulk-actions", children: [
|
|
3067
|
+
/* @__PURE__ */ jsx("span", { children: "Bulk actions:" }),
|
|
3068
|
+
hasCompletedOthers ? /* @__PURE__ */ jsx("button", { className: "btn btn-sm btn-danger", id: "delete-completed-btn", children: "Delete Completed" }) : null,
|
|
3069
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm btn-danger", id: "delete-all-btn", children: "Delete All" })
|
|
3070
|
+
] }) : null
|
|
3071
|
+
] });
|
|
3072
|
+
}) }),
|
|
3073
|
+
/* @__PURE__ */ jsx("a", { href: "/", className: "btn btn-link", style: "margin-top:16px;display:inline-block", children: "Back to current review" }),
|
|
3074
|
+
/* @__PURE__ */ jsx("script", { children: raw(getHistoryScript()) })
|
|
3075
|
+
] });
|
|
3076
|
+
}
|
|
3077
|
+
function getHistoryScript() {
|
|
3078
|
+
return `
|
|
3079
|
+
(function() {
|
|
3080
|
+
function esc(s) {
|
|
3081
|
+
var d = document.createElement('div');
|
|
3082
|
+
d.textContent = s;
|
|
3083
|
+
return d.innerHTML;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
function updateBulkVisibility() {
|
|
3087
|
+
var bulk = document.querySelector('.bulk-actions');
|
|
3088
|
+
if (bulk && !document.querySelector('.delete-review-btn')) {
|
|
3089
|
+
bulk.remove();
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
// Single review delete
|
|
3094
|
+
document.querySelectorAll('.delete-review-btn').forEach(function(btn) {
|
|
3095
|
+
btn.addEventListener('click', function(e) {
|
|
3096
|
+
e.preventDefault();
|
|
3097
|
+
e.stopPropagation();
|
|
3098
|
+
var id = btn.dataset.deleteId;
|
|
3099
|
+
showConfirm('Delete this review? This cannot be undone.', function() {
|
|
3100
|
+
fetch('/api/review/' + encodeURIComponent(id), { method: 'DELETE' })
|
|
3101
|
+
.then(function(r) { return r.json(); })
|
|
3102
|
+
.then(function() {
|
|
3103
|
+
btn.closest('.history-item-link').parentElement.remove();
|
|
3104
|
+
updateBulkVisibility();
|
|
3105
|
+
});
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
3108
|
+
});
|
|
3109
|
+
|
|
3110
|
+
// Bulk delete completed
|
|
3111
|
+
var delCompletedBtn = document.getElementById('delete-completed-btn');
|
|
3112
|
+
if (delCompletedBtn) {
|
|
3113
|
+
delCompletedBtn.addEventListener('click', function() {
|
|
3114
|
+
showConfirm('Delete all completed reviews (except current)? This cannot be undone.', function() {
|
|
3115
|
+
fetch('/api/reviews/delete-completed', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
|
|
3116
|
+
.then(function(r) { return r.json(); })
|
|
3117
|
+
.then(function() { location.reload(); });
|
|
3118
|
+
});
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
// Bulk delete all
|
|
3123
|
+
var delAllBtn = document.getElementById('delete-all-btn');
|
|
3124
|
+
if (delAllBtn) {
|
|
3125
|
+
delAllBtn.addEventListener('click', function() {
|
|
3126
|
+
showConfirm('Delete ALL reviews except the current one? This cannot be undone.', function() {
|
|
3127
|
+
fetch('/api/reviews/delete-all', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
|
|
3128
|
+
.then(function(r) { return r.json(); })
|
|
3129
|
+
.then(function() { location.reload(); });
|
|
3130
|
+
});
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function showConfirm(message, onConfirm) {
|
|
3135
|
+
var overlay = document.createElement('div');
|
|
3136
|
+
overlay.className = 'modal-overlay';
|
|
3137
|
+
overlay.innerHTML =
|
|
3138
|
+
'<div class="modal">' +
|
|
3139
|
+
'<h3>Confirm</h3>' +
|
|
3140
|
+
'<p>' + esc(message) + '</p>' +
|
|
3141
|
+
'<div class="modal-actions">' +
|
|
3142
|
+
'<button class="btn btn-sm modal-cancel">Cancel</button>' +
|
|
3143
|
+
'<button class="btn btn-sm btn-danger modal-confirm">Delete</button>' +
|
|
3144
|
+
'</div>' +
|
|
3145
|
+
'</div>';
|
|
3146
|
+
overlay.querySelector('.modal-cancel').addEventListener('click', function() { overlay.remove(); });
|
|
3147
|
+
overlay.querySelector('.modal-confirm').addEventListener('click', function() { overlay.remove(); onConfirm(); });
|
|
3148
|
+
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
|
|
3149
|
+
document.body.appendChild(overlay);
|
|
3150
|
+
}
|
|
3151
|
+
})();
|
|
3152
|
+
`;
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// src/routes/pages.tsx
|
|
3156
|
+
var pageRoutes = new Hono2();
|
|
3157
|
+
pageRoutes.get("/", async (c) => {
|
|
3158
|
+
const reviewId = c.get("reviewId");
|
|
3159
|
+
const review = await getReview(reviewId);
|
|
3160
|
+
if (!review) return c.text("Review not found", 404);
|
|
3161
|
+
const files = await getReviewFiles(reviewId);
|
|
3162
|
+
const annotationCounts = {};
|
|
3163
|
+
for (const f of files) {
|
|
3164
|
+
const anns = await getAnnotationsForFile(f.id);
|
|
3165
|
+
annotationCounts[f.id] = anns.length;
|
|
3166
|
+
}
|
|
3167
|
+
const html = /* @__PURE__ */ jsx(Layout, { title: `Glassbox - ${review.repo_name}`, reviewId, children: /* @__PURE__ */ jsx("div", { className: "review-app", "data-review-id": reviewId, children: [
|
|
3168
|
+
/* @__PURE__ */ jsx("aside", { className: "sidebar", children: [
|
|
3169
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-header", children: [
|
|
3170
|
+
/* @__PURE__ */ jsx("h2", { children: review.repo_name }),
|
|
3171
|
+
/* @__PURE__ */ jsx("span", { className: "review-mode", children: [
|
|
3172
|
+
review.mode,
|
|
3173
|
+
review.mode_args ? `: ${review.mode_args}` : ""
|
|
3174
|
+
] })
|
|
3175
|
+
] }),
|
|
3176
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-controls", children: /* @__PURE__ */ jsx("div", { className: "sidebar-controls-row", children: [
|
|
3177
|
+
/* @__PURE__ */ jsx("div", { className: "diff-mode-toggle", children: [
|
|
3178
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm active", "data-diff-mode": "split", children: "Split" }),
|
|
3179
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm", "data-diff-mode": "unified", children: "Unified" })
|
|
3180
|
+
] }),
|
|
3181
|
+
/* @__PURE__ */ jsx("span", { className: "controls-divider" }),
|
|
3182
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm", id: "wrap-toggle", children: "Wrap" })
|
|
3183
|
+
] }) }),
|
|
3184
|
+
/* @__PURE__ */ jsx("div", { className: "file-filter", children: /* @__PURE__ */ jsx("input", { type: "text", className: "file-filter-input", id: "file-filter", placeholder: "Filter files..." }) }),
|
|
3185
|
+
/* @__PURE__ */ jsx(FileList, { files, annotationCounts, staleCounts: {} }),
|
|
3186
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-footer", children: [
|
|
3187
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-primary btn-complete", id: "complete-review", children: "Complete Review" }),
|
|
3188
|
+
/* @__PURE__ */ jsx("a", { href: "/history", className: "btn btn-sm btn-link", children: "Review History" })
|
|
3189
|
+
] })
|
|
3190
|
+
] }),
|
|
3191
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-resize", id: "sidebar-resize" }),
|
|
3192
|
+
/* @__PURE__ */ jsx("main", { className: "main-content", children: [
|
|
3193
|
+
/* @__PURE__ */ jsx("div", { className: "welcome-message", children: [
|
|
3194
|
+
/* @__PURE__ */ jsx("h3", { children: "Select a file to begin reviewing" }),
|
|
3195
|
+
/* @__PURE__ */ jsx("p", { children: [
|
|
3196
|
+
files.length,
|
|
3197
|
+
" file(s) to review"
|
|
3198
|
+
] }),
|
|
3199
|
+
/* @__PURE__ */ jsx("p", { className: "progress-summary", id: "progress-summary" })
|
|
3200
|
+
] }),
|
|
3201
|
+
/* @__PURE__ */ jsx("div", { className: "diff-container", id: "diff-container", style: "display:none" })
|
|
3202
|
+
] })
|
|
3203
|
+
] }) });
|
|
3204
|
+
return c.html(html.toString());
|
|
3205
|
+
});
|
|
3206
|
+
pageRoutes.get("/file/:fileId", async (c) => {
|
|
3207
|
+
const fileId = c.req.param("fileId");
|
|
3208
|
+
const mode = c.req.query("mode") === "unified" ? "unified" : "split";
|
|
3209
|
+
const file = await getReviewFile(fileId);
|
|
3210
|
+
if (!file) return c.text("File not found", 404);
|
|
3211
|
+
const annotations = await getAnnotationsForFile(fileId);
|
|
3212
|
+
const diff = JSON.parse(file.diff_data || "{}");
|
|
3213
|
+
const html = /* @__PURE__ */ jsx(DiffView, { file, diff, annotations, mode });
|
|
3214
|
+
return c.html(html.toString());
|
|
3215
|
+
});
|
|
3216
|
+
pageRoutes.get("/review/:reviewId", async (c) => {
|
|
3217
|
+
const reviewId = c.req.param("reviewId");
|
|
3218
|
+
const currentReviewId = c.get("reviewId");
|
|
3219
|
+
if (reviewId === currentReviewId) {
|
|
3220
|
+
return c.redirect("/");
|
|
3221
|
+
}
|
|
3222
|
+
const review = await getReview(reviewId);
|
|
3223
|
+
if (!review) return c.text("Review not found", 404);
|
|
3224
|
+
const files = await getReviewFiles(reviewId);
|
|
3225
|
+
const annotationCounts = {};
|
|
3226
|
+
for (const f of files) {
|
|
3227
|
+
const anns = await getAnnotationsForFile(f.id);
|
|
3228
|
+
annotationCounts[f.id] = anns.length;
|
|
3229
|
+
}
|
|
3230
|
+
const html = /* @__PURE__ */ jsx(Layout, { title: `Glassbox - ${review.repo_name}`, reviewId, children: /* @__PURE__ */ jsx("div", { className: "review-app", "data-review-id": reviewId, children: [
|
|
3231
|
+
/* @__PURE__ */ jsx("aside", { className: "sidebar", children: [
|
|
3232
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-header", children: [
|
|
3233
|
+
/* @__PURE__ */ jsx("h2", { children: review.repo_name }),
|
|
3234
|
+
/* @__PURE__ */ jsx("span", { className: "review-mode", children: [
|
|
3235
|
+
review.mode,
|
|
3236
|
+
review.mode_args ? `: ${review.mode_args}` : ""
|
|
3237
|
+
] })
|
|
3238
|
+
] }),
|
|
3239
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-controls", children: /* @__PURE__ */ jsx("div", { className: "sidebar-controls-row", children: [
|
|
3240
|
+
/* @__PURE__ */ jsx("div", { className: "diff-mode-toggle", children: [
|
|
3241
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm active", "data-diff-mode": "split", children: "Split" }),
|
|
3242
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm", "data-diff-mode": "unified", children: "Unified" })
|
|
3243
|
+
] }),
|
|
3244
|
+
/* @__PURE__ */ jsx("span", { className: "controls-divider" }),
|
|
3245
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm", id: "wrap-toggle", children: "Wrap" })
|
|
3246
|
+
] }) }),
|
|
3247
|
+
/* @__PURE__ */ jsx("div", { className: "file-filter", children: /* @__PURE__ */ jsx("input", { type: "text", className: "file-filter-input", id: "file-filter", placeholder: "Filter files..." }) }),
|
|
3248
|
+
/* @__PURE__ */ jsx(FileList, { files, annotationCounts, staleCounts: {} }),
|
|
3249
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-footer", children: [
|
|
3250
|
+
review.status === "completed" ? /* @__PURE__ */ jsx("button", { className: "btn btn-primary", id: "reopen-review", children: "Reopen Review" }) : /* @__PURE__ */ jsx("button", { className: "btn btn-primary btn-complete", id: "complete-review", children: "Complete Review" }),
|
|
3251
|
+
/* @__PURE__ */ jsx("a", { href: "/history", className: "btn btn-sm btn-link", children: "Review History" }),
|
|
3252
|
+
/* @__PURE__ */ jsx("a", { href: "/", className: "btn btn-sm btn-link", children: "Back to current review" })
|
|
3253
|
+
] })
|
|
3254
|
+
] }),
|
|
3255
|
+
/* @__PURE__ */ jsx("div", { className: "sidebar-resize", id: "sidebar-resize" }),
|
|
3256
|
+
/* @__PURE__ */ jsx("main", { className: "main-content", children: [
|
|
3257
|
+
/* @__PURE__ */ jsx("div", { className: "welcome-message", children: [
|
|
3258
|
+
/* @__PURE__ */ jsx("h3", { children: "Select a file to begin reviewing" }),
|
|
3259
|
+
/* @__PURE__ */ jsx("p", { children: [
|
|
3260
|
+
files.length,
|
|
3261
|
+
" file(s) to review"
|
|
3262
|
+
] }),
|
|
3263
|
+
/* @__PURE__ */ jsx("p", { className: "progress-summary", id: "progress-summary" })
|
|
3264
|
+
] }),
|
|
3265
|
+
/* @__PURE__ */ jsx("div", { className: "diff-container", id: "diff-container", style: "display:none" })
|
|
3266
|
+
] })
|
|
3267
|
+
] }) });
|
|
3268
|
+
return c.html(html.toString());
|
|
3269
|
+
});
|
|
3270
|
+
pageRoutes.get("/history", async (c) => {
|
|
3271
|
+
const repoRoot = c.get("repoRoot");
|
|
3272
|
+
const currentReviewId = c.get("reviewId");
|
|
3273
|
+
const reviews = await listReviews(repoRoot);
|
|
3274
|
+
const html = /* @__PURE__ */ jsx(Layout, { title: "Review History", reviewId: "", children: /* @__PURE__ */ jsx(ReviewHistory, { reviews, currentReviewId }) });
|
|
3275
|
+
return c.html(html.toString());
|
|
3276
|
+
});
|
|
3277
|
+
|
|
3278
|
+
// src/server.ts
|
|
3279
|
+
function tryServe(fetch, port) {
|
|
3280
|
+
return new Promise((resolve2, reject) => {
|
|
3281
|
+
const server = serve({ fetch, port });
|
|
3282
|
+
server.on("listening", () => resolve2(port));
|
|
3283
|
+
server.on("error", (err) => {
|
|
3284
|
+
if (err.code === "EADDRINUSE") {
|
|
3285
|
+
reject(err);
|
|
3286
|
+
} else {
|
|
3287
|
+
reject(err);
|
|
3288
|
+
}
|
|
3289
|
+
});
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
async function startServer(port, reviewId, repoRoot) {
|
|
3293
|
+
const app = new Hono3();
|
|
3294
|
+
app.use("*", async (c, next) => {
|
|
3295
|
+
c.set("reviewId", reviewId);
|
|
3296
|
+
c.set("currentReviewId", reviewId);
|
|
3297
|
+
c.set("repoRoot", repoRoot);
|
|
3298
|
+
await next();
|
|
3299
|
+
});
|
|
3300
|
+
app.route("/api", apiRoutes);
|
|
3301
|
+
app.route("/", pageRoutes);
|
|
3302
|
+
let actualPort = port;
|
|
3303
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
3304
|
+
try {
|
|
3305
|
+
actualPort = await tryServe(app.fetch, port + attempt);
|
|
3306
|
+
break;
|
|
3307
|
+
} catch (err) {
|
|
3308
|
+
if (err.code === "EADDRINUSE" && attempt < 19) {
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
throw err;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
if (actualPort !== port) {
|
|
3315
|
+
console.log(` Port ${port} in use, using ${actualPort} instead.`);
|
|
3316
|
+
}
|
|
3317
|
+
const url = `http://localhost:${actualPort}`;
|
|
3318
|
+
console.log(`
|
|
3319
|
+
Glassbox running at ${url}
|
|
3320
|
+
`);
|
|
3321
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
3322
|
+
exec(`${openCmd} ${url}`);
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// src/update-check.ts
|
|
3326
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync2 } from "fs";
|
|
3327
|
+
import { join as join3, dirname } from "path";
|
|
3328
|
+
import { homedir as homedir3 } from "os";
|
|
3329
|
+
import { get } from "https";
|
|
3330
|
+
import { fileURLToPath } from "url";
|
|
3331
|
+
var DATA_DIR = join3(homedir3(), ".glassbox");
|
|
3332
|
+
var CHECK_FILE = join3(DATA_DIR, "last-update-check");
|
|
3333
|
+
var PACKAGE_NAME = "glassbox";
|
|
3334
|
+
function getCurrentVersion() {
|
|
3335
|
+
try {
|
|
3336
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3337
|
+
const pkg = JSON.parse(readFileSync3(join3(dir, "..", "package.json"), "utf-8"));
|
|
3338
|
+
return pkg.version;
|
|
3339
|
+
} catch {
|
|
3340
|
+
return "0.0.0";
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
function getLastCheckDate() {
|
|
3344
|
+
try {
|
|
3345
|
+
if (existsSync2(CHECK_FILE)) {
|
|
3346
|
+
return readFileSync3(CHECK_FILE, "utf-8").trim();
|
|
3347
|
+
}
|
|
3348
|
+
} catch {
|
|
3349
|
+
}
|
|
3350
|
+
return null;
|
|
3351
|
+
}
|
|
3352
|
+
function saveCheckDate() {
|
|
3353
|
+
mkdirSync3(DATA_DIR, { recursive: true });
|
|
3354
|
+
writeFileSync2(CHECK_FILE, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "utf-8");
|
|
3355
|
+
}
|
|
3356
|
+
function isFirstUseToday() {
|
|
3357
|
+
const last = getLastCheckDate();
|
|
3358
|
+
if (!last) return true;
|
|
3359
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3360
|
+
return last !== today;
|
|
3361
|
+
}
|
|
3362
|
+
function fetchLatestVersion() {
|
|
3363
|
+
return new Promise((resolve2) => {
|
|
3364
|
+
const req = get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { timeout: 5e3 }, (res) => {
|
|
3365
|
+
if (res.statusCode !== 200) {
|
|
3366
|
+
resolve2(null);
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
let data = "";
|
|
3370
|
+
res.on("data", (chunk) => {
|
|
3371
|
+
data += chunk;
|
|
3372
|
+
});
|
|
3373
|
+
res.on("end", () => {
|
|
3374
|
+
try {
|
|
3375
|
+
resolve2(JSON.parse(data).version);
|
|
3376
|
+
} catch {
|
|
3377
|
+
resolve2(null);
|
|
3378
|
+
}
|
|
3379
|
+
});
|
|
3380
|
+
});
|
|
3381
|
+
req.on("error", () => resolve2(null));
|
|
3382
|
+
req.on("timeout", () => {
|
|
3383
|
+
req.destroy();
|
|
3384
|
+
resolve2(null);
|
|
3385
|
+
});
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
function detectUpgradeCommand() {
|
|
3389
|
+
const binPath = process.argv[1] || "";
|
|
3390
|
+
if (binPath.includes("/.bun/") || binPath.includes("/bun/")) {
|
|
3391
|
+
return `bun update -g ${PACKAGE_NAME}`;
|
|
3392
|
+
}
|
|
3393
|
+
if (binPath.includes("/.pnpm/") || binPath.includes("/pnpm/")) {
|
|
3394
|
+
return `pnpm update -g ${PACKAGE_NAME}`;
|
|
3395
|
+
}
|
|
3396
|
+
if (binPath.includes("/.yarn/") || binPath.includes("/yarn/")) {
|
|
3397
|
+
return `yarn global upgrade ${PACKAGE_NAME}`;
|
|
3398
|
+
}
|
|
3399
|
+
return `npm update -g ${PACKAGE_NAME}`;
|
|
3400
|
+
}
|
|
3401
|
+
function compareVersions(current, latest) {
|
|
3402
|
+
const a = current.split(".").map(Number);
|
|
3403
|
+
const b = latest.split(".").map(Number);
|
|
3404
|
+
for (let i = 0; i < 3; i++) {
|
|
3405
|
+
if ((a[i] || 0) < (b[i] || 0)) return -1;
|
|
3406
|
+
if ((a[i] || 0) > (b[i] || 0)) return 1;
|
|
3407
|
+
}
|
|
3408
|
+
return 0;
|
|
3409
|
+
}
|
|
3410
|
+
async function checkForUpdates(force) {
|
|
3411
|
+
if (!force && !isFirstUseToday()) return;
|
|
3412
|
+
const current = getCurrentVersion();
|
|
3413
|
+
const latest = await fetchLatestVersion();
|
|
3414
|
+
saveCheckDate();
|
|
3415
|
+
if (!latest || compareVersions(current, latest) >= 0) return;
|
|
3416
|
+
const cmd = detectUpgradeCommand();
|
|
3417
|
+
const updateLine = `Update available: ${current} \u2192 ${latest}`;
|
|
3418
|
+
const cmdLine = `Run: ${cmd}`;
|
|
3419
|
+
const width = Math.max(updateLine.length, cmdLine.length) + 4;
|
|
3420
|
+
const pad = (text, visLen) => text + " ".repeat(Math.max(0, width - visLen));
|
|
3421
|
+
const border = "\u2500".repeat(width);
|
|
3422
|
+
const empty = " ".repeat(width);
|
|
3423
|
+
console.log("");
|
|
3424
|
+
console.log(` \u250C${border}\u2510`);
|
|
3425
|
+
console.log(` \u2502${empty}\u2502`);
|
|
3426
|
+
console.log(` \u2502 ${pad(`Update available: ${current} \u2192 \x1B[32m${latest}\x1B[0m`, updateLine.length + 2)}\u2502`);
|
|
3427
|
+
console.log(` \u2502${empty}\u2502`);
|
|
3428
|
+
console.log(` \u2502 ${pad(`Run: \x1B[36m${cmd}\x1B[0m`, cmdLine.length + 2)}\u2502`);
|
|
3429
|
+
console.log(` \u2502${empty}\u2502`);
|
|
3430
|
+
console.log(` \u2514${border}\u2518`);
|
|
3431
|
+
console.log("");
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// src/review-update.ts
|
|
3435
|
+
function findLineContent(diff, lineNumber, side) {
|
|
3436
|
+
for (const hunk of diff.hunks || []) {
|
|
3437
|
+
for (const line of hunk.lines) {
|
|
3438
|
+
if (side === "old" && line.oldNum === lineNumber) return line.content;
|
|
3439
|
+
if (side === "new" && line.newNum === lineNumber) return line.content;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
return null;
|
|
3443
|
+
}
|
|
3444
|
+
function findMatchingLine(diff, content, origLineNum, side, radius = 10) {
|
|
3445
|
+
let bestMatch = null;
|
|
3446
|
+
let bestDistance = Infinity;
|
|
3447
|
+
for (const hunk of diff.hunks || []) {
|
|
3448
|
+
for (const line of hunk.lines) {
|
|
3449
|
+
if (line.content !== content) continue;
|
|
3450
|
+
const lineNum = side === "old" ? line.oldNum : line.newNum;
|
|
3451
|
+
if (lineNum == null) continue;
|
|
3452
|
+
const distance = Math.abs(lineNum - origLineNum);
|
|
3453
|
+
if (distance <= radius && distance < bestDistance) {
|
|
3454
|
+
bestDistance = distance;
|
|
3455
|
+
bestMatch = { lineNumber: lineNum, side };
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
return bestMatch;
|
|
3460
|
+
}
|
|
3461
|
+
async function migrateAnnotations(annotations, oldDiff, newDiff) {
|
|
3462
|
+
let staleCount = 0;
|
|
3463
|
+
for (const annotation of annotations) {
|
|
3464
|
+
const oldContent = findLineContent(oldDiff, annotation.line_number, annotation.side);
|
|
3465
|
+
if (!oldContent) {
|
|
3466
|
+
if (!annotation.is_stale) {
|
|
3467
|
+
await markAnnotationStale(annotation.id, null);
|
|
3468
|
+
staleCount++;
|
|
3469
|
+
}
|
|
3470
|
+
continue;
|
|
3471
|
+
}
|
|
3472
|
+
const match = findMatchingLine(newDiff, oldContent, annotation.line_number, annotation.side);
|
|
3473
|
+
if (match) {
|
|
3474
|
+
if (match.lineNumber !== annotation.line_number || match.side !== annotation.side) {
|
|
3475
|
+
await moveAnnotation(annotation.id, match.lineNumber, match.side);
|
|
3476
|
+
} else if (annotation.is_stale) {
|
|
3477
|
+
await markAnnotationCurrent(annotation.id);
|
|
3478
|
+
}
|
|
3479
|
+
} else {
|
|
3480
|
+
if (!annotation.is_stale) {
|
|
3481
|
+
await markAnnotationStale(annotation.id, oldContent);
|
|
3482
|
+
staleCount++;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
return staleCount;
|
|
3487
|
+
}
|
|
3488
|
+
async function updateReviewDiffs(reviewId, newDiffs, headCommit) {
|
|
3489
|
+
const existingFiles = await getReviewFiles(reviewId);
|
|
3490
|
+
const existingByPath = /* @__PURE__ */ new Map();
|
|
3491
|
+
for (const f of existingFiles) {
|
|
3492
|
+
existingByPath.set(f.file_path, f);
|
|
3493
|
+
}
|
|
3494
|
+
const newDiffsByPath = /* @__PURE__ */ new Map();
|
|
3495
|
+
for (const d of newDiffs) {
|
|
3496
|
+
newDiffsByPath.set(d.filePath, d);
|
|
3497
|
+
}
|
|
3498
|
+
let updated = 0;
|
|
3499
|
+
let added = 0;
|
|
3500
|
+
let stale = 0;
|
|
3501
|
+
for (const [path, existingFile] of existingByPath) {
|
|
3502
|
+
const newDiff = newDiffsByPath.get(path);
|
|
3503
|
+
if (newDiff) {
|
|
3504
|
+
const oldDiff = JSON.parse(existingFile.diff_data || "{}");
|
|
3505
|
+
const annotations = await getAnnotationsForFile(existingFile.id);
|
|
3506
|
+
if (annotations.length > 0) {
|
|
3507
|
+
stale += await migrateAnnotations(annotations, oldDiff, newDiff);
|
|
3508
|
+
}
|
|
3509
|
+
await updateFileDiff(existingFile.id, JSON.stringify(newDiff));
|
|
3510
|
+
updated++;
|
|
3511
|
+
} else {
|
|
3512
|
+
const annotations = await getAnnotationsForFile(existingFile.id);
|
|
3513
|
+
if (annotations.length === 0) {
|
|
3514
|
+
await deleteReviewFile(existingFile.id);
|
|
3515
|
+
} else {
|
|
3516
|
+
const oldDiff = JSON.parse(existingFile.diff_data || "{}");
|
|
3517
|
+
for (const a of annotations) {
|
|
3518
|
+
if (!a.is_stale) {
|
|
3519
|
+
const content = findLineContent(oldDiff, a.line_number, a.side);
|
|
3520
|
+
await markAnnotationStale(a.id, content);
|
|
3521
|
+
stale++;
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
for (const [path, diff] of newDiffsByPath) {
|
|
3528
|
+
if (!existingByPath.has(path)) {
|
|
3529
|
+
await addReviewFile(reviewId, path, JSON.stringify(diff));
|
|
3530
|
+
added++;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
await updateReviewHead(reviewId, headCommit);
|
|
3534
|
+
return { updated, added, stale };
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
// src/cli.ts
|
|
3538
|
+
function printUsage() {
|
|
3539
|
+
console.log(`
|
|
3540
|
+
glassbox - Review AI-generated code with annotations
|
|
3541
|
+
|
|
3542
|
+
Usage:
|
|
3543
|
+
glassbox [options]
|
|
3544
|
+
|
|
3545
|
+
Modes (pick one):
|
|
3546
|
+
--uncommitted Review all uncommitted changes (staged + unstaged + untracked)
|
|
3547
|
+
--staged Review only staged changes
|
|
3548
|
+
--unstaged Review only unstaged changes
|
|
3549
|
+
--commit <sha> Review changes from a specific commit
|
|
3550
|
+
--range <from>..<to> Review changes between two refs
|
|
3551
|
+
--branch <name> Review changes on current branch vs <name>
|
|
3552
|
+
--files <patterns> Review specific files (glob patterns, comma-separated)
|
|
3553
|
+
--all Review entire codebase
|
|
3554
|
+
|
|
3555
|
+
Options:
|
|
3556
|
+
--port <number> Port to run on (default: 4173)
|
|
3557
|
+
--resume Resume the latest in-progress review for this mode
|
|
3558
|
+
--check-for-updates Check for a newer version on npm
|
|
3559
|
+
--help Show this help message
|
|
3560
|
+
|
|
3561
|
+
Examples:
|
|
3562
|
+
glassbox --uncommitted
|
|
3563
|
+
glassbox --commit abc123
|
|
3564
|
+
glassbox --branch main
|
|
3565
|
+
glassbox --files "src/**/*.ts,lib/*.js"
|
|
3566
|
+
glassbox --all --resume
|
|
3567
|
+
`);
|
|
3568
|
+
}
|
|
3569
|
+
function parseArgs(argv) {
|
|
3570
|
+
const args = argv.slice(2);
|
|
3571
|
+
let mode = null;
|
|
3572
|
+
let port = 4173;
|
|
3573
|
+
let resume = false;
|
|
3574
|
+
let forceUpdateCheck = false;
|
|
3575
|
+
let debug = false;
|
|
3576
|
+
for (let i = 0; i < args.length; i++) {
|
|
3577
|
+
const arg = args[i];
|
|
3578
|
+
switch (arg) {
|
|
3579
|
+
case "--help":
|
|
3580
|
+
case "-h":
|
|
3581
|
+
printUsage();
|
|
3582
|
+
process.exit(0);
|
|
3583
|
+
case "--uncommitted":
|
|
3584
|
+
mode = { type: "uncommitted" };
|
|
3585
|
+
break;
|
|
3586
|
+
case "--staged":
|
|
3587
|
+
mode = { type: "staged" };
|
|
3588
|
+
break;
|
|
3589
|
+
case "--unstaged":
|
|
3590
|
+
mode = { type: "unstaged" };
|
|
3591
|
+
break;
|
|
3592
|
+
case "--commit":
|
|
3593
|
+
mode = { type: "commit", sha: args[++i] };
|
|
3594
|
+
break;
|
|
3595
|
+
case "--range": {
|
|
3596
|
+
const parts = args[++i].split("..");
|
|
3597
|
+
mode = { type: "range", from: parts[0], to: parts[1] || "HEAD" };
|
|
3598
|
+
break;
|
|
3599
|
+
}
|
|
3600
|
+
case "--branch":
|
|
3601
|
+
mode = { type: "branch", name: args[++i] };
|
|
3602
|
+
break;
|
|
3603
|
+
case "--files":
|
|
3604
|
+
mode = { type: "files", patterns: args[++i].split(",") };
|
|
3605
|
+
break;
|
|
3606
|
+
case "--all":
|
|
3607
|
+
mode = { type: "all" };
|
|
3608
|
+
break;
|
|
3609
|
+
case "--port":
|
|
3610
|
+
port = parseInt(args[++i], 10);
|
|
3611
|
+
break;
|
|
3612
|
+
case "--resume":
|
|
3613
|
+
resume = true;
|
|
3614
|
+
break;
|
|
3615
|
+
case "--check-for-updates":
|
|
3616
|
+
forceUpdateCheck = true;
|
|
3617
|
+
break;
|
|
3618
|
+
case "--debug":
|
|
3619
|
+
debug = true;
|
|
3620
|
+
break;
|
|
3621
|
+
default:
|
|
3622
|
+
console.error(`Unknown option: ${arg}`);
|
|
3623
|
+
printUsage();
|
|
3624
|
+
process.exit(1);
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
if (!mode) {
|
|
3628
|
+
mode = { type: "uncommitted" };
|
|
3629
|
+
}
|
|
3630
|
+
return { mode, port, resume, forceUpdateCheck, debug };
|
|
3631
|
+
}
|
|
3632
|
+
async function main() {
|
|
3633
|
+
const parsed = parseArgs(process.argv);
|
|
3634
|
+
if (!parsed) {
|
|
3635
|
+
printUsage();
|
|
3636
|
+
process.exit(1);
|
|
3637
|
+
}
|
|
3638
|
+
const { mode, port, resume, forceUpdateCheck, debug } = parsed;
|
|
3639
|
+
if (debug) {
|
|
3640
|
+
console.log(`Build timestamp: ${"2026-03-05T06:52:47.945Z"}`);
|
|
3641
|
+
}
|
|
3642
|
+
await checkForUpdates(forceUpdateCheck);
|
|
3643
|
+
const cwd = process.cwd();
|
|
3644
|
+
if (!isGitRepo(cwd)) {
|
|
3645
|
+
console.error("Error: Not a git repository. Run this from inside a git repo.");
|
|
3646
|
+
process.exit(1);
|
|
3647
|
+
}
|
|
3648
|
+
const repoRoot = getRepoRoot(cwd);
|
|
3649
|
+
const repoName = getRepoName(cwd);
|
|
3650
|
+
const modeStr = getModeString(mode);
|
|
3651
|
+
const modeArgs = getModeArgs(mode);
|
|
3652
|
+
const headCommit = getHeadCommit(cwd);
|
|
3653
|
+
const existing = await getLatestInProgressReview(repoRoot, modeStr, modeArgs);
|
|
3654
|
+
if (existing) {
|
|
3655
|
+
if (existing.head_commit === headCommit) {
|
|
3656
|
+
console.log(`Updating existing review ${existing.id}...`);
|
|
3657
|
+
const diffs2 = getFileDiffs(mode, cwd);
|
|
3658
|
+
const result = await updateReviewDiffs(existing.id, diffs2, headCommit);
|
|
3659
|
+
console.log(`Updated ${result.updated} file(s), ${result.added} added, ${result.stale} stale annotation(s)`);
|
|
3660
|
+
await startServer(port, existing.id, repoRoot);
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
if (resume) {
|
|
3664
|
+
console.log(`Resuming review ${existing.id} (started ${existing.created_at})`);
|
|
3665
|
+
await startServer(port, existing.id, repoRoot);
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
} else if (resume) {
|
|
3669
|
+
console.log("No in-progress review found, starting a new one.");
|
|
3670
|
+
}
|
|
3671
|
+
console.log(`Scanning ${modeStr} changes in ${repoName}...`);
|
|
3672
|
+
const diffs = getFileDiffs(mode, cwd);
|
|
3673
|
+
if (diffs.length === 0) {
|
|
3674
|
+
console.log("No changes found for the specified mode.");
|
|
3675
|
+
process.exit(0);
|
|
3676
|
+
}
|
|
3677
|
+
console.log(`Found ${diffs.length} file(s) to review.`);
|
|
3678
|
+
const review = await createReview(repoRoot, repoName, modeStr, modeArgs, headCommit);
|
|
3679
|
+
for (const diff of diffs) {
|
|
3680
|
+
await addReviewFile(review.id, diff.filePath, JSON.stringify(diff));
|
|
3681
|
+
}
|
|
3682
|
+
console.log(`Review ${review.id} created.`);
|
|
3683
|
+
await startServer(port, review.id, repoRoot);
|
|
3684
|
+
}
|
|
3685
|
+
main().catch((err) => {
|
|
3686
|
+
console.error(err);
|
|
3687
|
+
process.exit(1);
|
|
3688
|
+
});
|
|
3689
|
+
//# sourceMappingURL=cli.js.map
|