glassbox 0.2.7 → 0.3.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/dist/cli.js CHANGED
@@ -87,7 +87,8 @@ var init_schema = __esm({
87
87
  id TEXT PRIMARY KEY DEFAULT 'singleton',
88
88
  sort_mode TEXT NOT NULL DEFAULT 'folder',
89
89
  risk_sort_dimension TEXT NOT NULL DEFAULT 'aggregate',
90
- show_risk_scores BOOLEAN NOT NULL DEFAULT FALSE
90
+ show_risk_scores BOOLEAN NOT NULL DEFAULT FALSE,
91
+ ignore_whitespace BOOLEAN NOT NULL DEFAULT FALSE
91
92
  );
92
93
  `;
93
94
  }
@@ -142,6 +143,7 @@ async function initSchema(db2) {
142
143
  await addColumnIfMissing(db2, "ai_file_scores", "notes", "TEXT");
143
144
  await addColumnIfMissing(db2, "ai_analyses", "progress_completed", "INTEGER NOT NULL DEFAULT 0");
144
145
  await addColumnIfMissing(db2, "ai_analyses", "progress_total", "INTEGER NOT NULL DEFAULT 0");
146
+ await addColumnIfMissing(db2, "user_preferences", "ignore_whitespace", "BOOLEAN NOT NULL DEFAULT FALSE");
145
147
  await db2.exec(
146
148
  `UPDATE ai_analyses SET status = 'failed', error_message = 'Interrupted (server restarted)' WHERE status = 'running'`
147
149
  );
@@ -389,7 +391,7 @@ init_queries();
389
391
  init_connection();
390
392
  import { mkdirSync as mkdirSync7 } from "fs";
391
393
  import { tmpdir } from "os";
392
- import { join as join9, resolve as resolve2 } from "path";
394
+ import { join as join9, resolve as resolve3 } from "path";
393
395
 
394
396
  // src/debug.ts
395
397
  var debugEnabled = false;
@@ -798,7 +800,7 @@ async function getUserPreferences() {
798
800
  ["singleton"]
799
801
  );
800
802
  if (result.rows.length === 0) {
801
- return { sort_mode: "folder", risk_sort_dimension: "aggregate", show_risk_scores: false };
803
+ return { sort_mode: "folder", risk_sort_dimension: "aggregate", show_risk_scores: false, ignore_whitespace: false };
802
804
  }
803
805
  return result.rows[0];
804
806
  }
@@ -807,13 +809,14 @@ async function saveUserPreferences(prefs) {
807
809
  const current = await getUserPreferences();
808
810
  const merged = { ...current, ...prefs };
809
811
  await db2.query(
810
- `INSERT INTO user_preferences (id, sort_mode, risk_sort_dimension, show_risk_scores)
811
- VALUES ($1, $2, $3, $4)
812
+ `INSERT INTO user_preferences (id, sort_mode, risk_sort_dimension, show_risk_scores, ignore_whitespace)
813
+ VALUES ($1, $2, $3, $4, $5)
812
814
  ON CONFLICT (id) DO UPDATE SET
813
815
  sort_mode = EXCLUDED.sort_mode,
814
816
  risk_sort_dimension = EXCLUDED.risk_sort_dimension,
815
- show_risk_scores = EXCLUDED.show_risk_scores`,
816
- ["singleton", merged.sort_mode, merged.risk_sort_dimension, merged.show_risk_scores]
817
+ show_risk_scores = EXCLUDED.show_risk_scores,
818
+ ignore_whitespace = EXCLUDED.ignore_whitespace`,
819
+ ["singleton", merged.sort_mode, merged.risk_sort_dimension, merged.show_risk_scores, merged.ignore_whitespace]
817
820
  );
818
821
  }
819
822
 
@@ -1493,6 +1496,34 @@ function getFileContent(filePath, ref, cwd) {
1493
1496
  function getHeadCommit(cwd) {
1494
1497
  return execSync2("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
1495
1498
  }
1499
+ function parseModeString(modeStr) {
1500
+ if (modeStr === "uncommitted") return { type: "uncommitted" };
1501
+ if (modeStr === "staged") return { type: "staged" };
1502
+ if (modeStr === "unstaged") return { type: "unstaged" };
1503
+ if (modeStr === "all") return { type: "all" };
1504
+ if (modeStr.startsWith("commit:")) return { type: "commit", sha: modeStr.slice(7) };
1505
+ if (modeStr.startsWith("range:")) {
1506
+ const parts = modeStr.slice(6).split("..");
1507
+ return { type: "range", from: parts[0], to: parts[1] || "HEAD" };
1508
+ }
1509
+ if (modeStr.startsWith("branch:")) return { type: "branch", name: modeStr.slice(7) };
1510
+ if (modeStr.startsWith("files:")) return { type: "files", patterns: modeStr.slice(6).split(",") };
1511
+ return { type: "uncommitted" };
1512
+ }
1513
+ function getSingleFileDiff(mode, filePath, repoRoot, extraFlags = "") {
1514
+ if (mode.type === "all") {
1515
+ return createNewFileDiff(filePath, repoRoot);
1516
+ }
1517
+ const diffArgs = getDiffArgs(mode);
1518
+ let rawDiff;
1519
+ try {
1520
+ rawDiff = git(`${diffArgs} -U3 ${extraFlags} -- ${filePath}`, repoRoot);
1521
+ } catch {
1522
+ rawDiff = "";
1523
+ }
1524
+ const diffs = parseDiff(rawDiff);
1525
+ return diffs[0] ?? null;
1526
+ }
1496
1527
  function getModeString(mode) {
1497
1528
  switch (mode.type) {
1498
1529
  case "uncommitted":
@@ -1686,7 +1717,7 @@ async function updateReviewDiffs(reviewId, newDiffs, headCommit) {
1686
1717
  // src/server.ts
1687
1718
  import { serve } from "@hono/node-server";
1688
1719
  import { exec } from "child_process";
1689
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
1720
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
1690
1721
  import { Hono as Hono4 } from "hono";
1691
1722
  import { dirname, join as join6 } from "path";
1692
1723
  import { fileURLToPath } from "url";
@@ -2408,8 +2439,8 @@ function isRetriable(err) {
2408
2439
  return msg.includes("429") || msg.includes("500") || msg.includes("502") || msg.includes("503") || msg.includes("504") || msg.includes("rate_limit");
2409
2440
  }
2410
2441
  function sleep(ms) {
2411
- return new Promise((resolve3) => {
2412
- setTimeout(resolve3, ms);
2442
+ return new Promise((resolve4) => {
2443
+ setTimeout(resolve4, ms);
2413
2444
  });
2414
2445
  }
2415
2446
 
@@ -2453,8 +2484,8 @@ function randomLines(count) {
2453
2484
  return lines.sort((a, b) => a.line - b.line);
2454
2485
  }
2455
2486
  function sleep2(ms) {
2456
- return new Promise((resolve3) => {
2457
- setTimeout(resolve3, ms);
2487
+ return new Promise((resolve4) => {
2488
+ setTimeout(resolve4, ms);
2458
2489
  });
2459
2490
  }
2460
2491
  async function mockRiskAnalysisBatch(files) {
@@ -2906,7 +2937,7 @@ aiApiRoutes.post("/preferences", async (c) => {
2906
2937
 
2907
2938
  // src/routes/api.ts
2908
2939
  init_queries();
2909
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2940
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2910
2941
  import { Hono as Hono2 } from "hono";
2911
2942
  import { join as join5 } from "path";
2912
2943
 
@@ -3052,6 +3083,198 @@ async function generateReviewExport(reviewId, repoRoot, isCurrent) {
3052
3083
  return archivePath;
3053
3084
  }
3054
3085
 
3086
+ // src/git/image.ts
3087
+ import { execSync as execSync4 } from "child_process";
3088
+ import { readFileSync as readFileSync5 } from "fs";
3089
+ import { resolve as resolve2 } from "path";
3090
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
3091
+ function isImageFile(filePath) {
3092
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3093
+ return IMAGE_EXTENSIONS.has(ext);
3094
+ }
3095
+ function getContentType(filePath) {
3096
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3097
+ switch (ext) {
3098
+ case ".png":
3099
+ return "image/png";
3100
+ case ".jpg":
3101
+ case ".jpeg":
3102
+ return "image/jpeg";
3103
+ case ".gif":
3104
+ return "image/gif";
3105
+ case ".webp":
3106
+ return "image/webp";
3107
+ case ".svg":
3108
+ return "image/svg+xml";
3109
+ default:
3110
+ return "application/octet-stream";
3111
+ }
3112
+ }
3113
+ function getOldRef(mode) {
3114
+ switch (mode.type) {
3115
+ case "uncommitted":
3116
+ return "HEAD";
3117
+ case "staged":
3118
+ return "HEAD";
3119
+ case "unstaged":
3120
+ return null;
3121
+ // old = index, use ':'
3122
+ case "commit":
3123
+ return `${mode.sha}~1`;
3124
+ case "range":
3125
+ return mode.from;
3126
+ case "branch":
3127
+ return mode.name;
3128
+ case "files":
3129
+ return "HEAD";
3130
+ case "all":
3131
+ return null;
3132
+ }
3133
+ }
3134
+ function getNewRef(mode) {
3135
+ switch (mode.type) {
3136
+ case "uncommitted":
3137
+ return null;
3138
+ // working tree
3139
+ case "staged":
3140
+ return null;
3141
+ // index, but git show : works
3142
+ case "unstaged":
3143
+ return null;
3144
+ // working tree
3145
+ case "commit":
3146
+ return mode.sha;
3147
+ case "range":
3148
+ return mode.to;
3149
+ case "branch":
3150
+ return "HEAD";
3151
+ case "files":
3152
+ return null;
3153
+ case "all":
3154
+ return null;
3155
+ }
3156
+ }
3157
+ function gitShowFile(ref, filePath, repoRoot) {
3158
+ try {
3159
+ const spec = ref === ":" ? `:${filePath}` : `${ref}:${filePath}`;
3160
+ return execSync4(`git show "${spec}"`, { cwd: repoRoot, maxBuffer: 50 * 1024 * 1024 });
3161
+ } catch {
3162
+ return null;
3163
+ }
3164
+ }
3165
+ function readWorkingFile(filePath, repoRoot) {
3166
+ try {
3167
+ return readFileSync5(resolve2(repoRoot, filePath));
3168
+ } catch {
3169
+ return null;
3170
+ }
3171
+ }
3172
+ function getOldImage(mode, filePath, oldPath, repoRoot) {
3173
+ const ref = getOldRef(mode);
3174
+ const path = oldPath ?? filePath;
3175
+ if (ref === null) {
3176
+ const data2 = readWorkingFile(path, repoRoot);
3177
+ if (!data2) return null;
3178
+ return { data: data2, size: data2.length };
3179
+ }
3180
+ const actualRef = mode.type === "unstaged" ? ":" : ref;
3181
+ const data = gitShowFile(actualRef, path, repoRoot);
3182
+ if (!data) return null;
3183
+ return { data, size: data.length };
3184
+ }
3185
+ function getNewImage(mode, filePath, repoRoot) {
3186
+ const ref = getNewRef(mode);
3187
+ if (ref === null) {
3188
+ if (mode.type === "staged") {
3189
+ const data3 = gitShowFile(":", filePath, repoRoot);
3190
+ if (!data3) return null;
3191
+ return { data: data3, size: data3.length };
3192
+ }
3193
+ const data2 = readWorkingFile(filePath, repoRoot);
3194
+ if (!data2) return null;
3195
+ return { data: data2, size: data2.length };
3196
+ }
3197
+ const data = gitShowFile(ref, filePath, repoRoot);
3198
+ if (!data) return null;
3199
+ return { data, size: data.length };
3200
+ }
3201
+ async function extractMetadata(data, filePath) {
3202
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3203
+ if (ext === ".svg") {
3204
+ const text = data.toString("utf-8");
3205
+ const widthMatch = text.match(/\bwidth\s*=\s*["']([^"']+)["']/);
3206
+ const heightMatch = text.match(/\bheight\s*=\s*["']([^"']+)["']/);
3207
+ const viewBoxMatch = text.match(/\bviewBox\s*=\s*["']([^"']+)["']/);
3208
+ let width = widthMatch ? parseFloat(widthMatch[1]) : null;
3209
+ let height = heightMatch ? parseFloat(heightMatch[1]) : null;
3210
+ if (width === null && height === null && viewBoxMatch) {
3211
+ const parts = viewBoxMatch[1].split(/[\s,]+/);
3212
+ if (parts.length >= 4) {
3213
+ width = parseFloat(parts[2]);
3214
+ height = parseFloat(parts[3]);
3215
+ }
3216
+ }
3217
+ return {
3218
+ format: "svg",
3219
+ width: width !== null && !isNaN(width) ? width : null,
3220
+ height: height !== null && !isNaN(height) ? height : null,
3221
+ fileSize: data.length,
3222
+ colorSpace: null,
3223
+ channels: null,
3224
+ depth: null,
3225
+ hasAlpha: null,
3226
+ density: null,
3227
+ exif: null
3228
+ };
3229
+ }
3230
+ const sharp = (await import("sharp")).default;
3231
+ const meta = await sharp(data).metadata();
3232
+ let exif = null;
3233
+ if (meta.exif) {
3234
+ try {
3235
+ exif = {};
3236
+ if (meta.orientation) exif["Orientation"] = String(meta.orientation);
3237
+ } catch {
3238
+ }
3239
+ }
3240
+ return {
3241
+ format: meta.format ?? ext.slice(1),
3242
+ width: meta.width ?? null,
3243
+ height: meta.height ?? null,
3244
+ fileSize: data.length,
3245
+ colorSpace: meta.space ?? null,
3246
+ channels: meta.channels ?? null,
3247
+ depth: meta.depth ?? null,
3248
+ hasAlpha: meta.hasAlpha ?? null,
3249
+ density: meta.density ?? null,
3250
+ exif
3251
+ };
3252
+ }
3253
+ function formatMetadataLines(meta) {
3254
+ const lines = [];
3255
+ lines.push(`Format: ${meta.format}`);
3256
+ if (meta.width !== null && meta.height !== null) {
3257
+ lines.push(`Dimensions: ${meta.width} \xD7 ${meta.height}`);
3258
+ }
3259
+ lines.push(`File size: ${formatBytes(meta.fileSize)}`);
3260
+ if (meta.colorSpace) lines.push(`Color space: ${meta.colorSpace}`);
3261
+ if (meta.channels !== null) lines.push(`Channels: ${meta.channels}`);
3262
+ if (meta.depth) lines.push(`Bit depth: ${meta.depth}`);
3263
+ if (meta.hasAlpha !== null) lines.push(`Alpha: ${meta.hasAlpha ? "yes" : "no"}`);
3264
+ if (meta.density !== null) lines.push(`Density: ${meta.density} DPI`);
3265
+ if (meta.exif) {
3266
+ for (const [key, value] of Object.entries(meta.exif)) {
3267
+ lines.push(`EXIF ${key}: ${value}`);
3268
+ }
3269
+ }
3270
+ return lines;
3271
+ }
3272
+ function formatBytes(bytes) {
3273
+ if (bytes < 1024) return `${bytes} B`;
3274
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
3275
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3276
+ }
3277
+
3055
3278
  // src/outline/parser.ts
3056
3279
  var BRACE_LANGS = /* @__PURE__ */ new Set([
3057
3280
  "javascript",
@@ -3410,6 +3633,22 @@ apiRoutes.post("/review/reopen", async (c) => {
3410
3633
  await updateReviewStatus(reviewId, "in_progress");
3411
3634
  return c.json({ status: "in_progress" });
3412
3635
  });
3636
+ apiRoutes.post("/review/refresh", async (c) => {
3637
+ const reviewId = resolveReviewId(c);
3638
+ const repoRoot = c.get("repoRoot");
3639
+ const review = await getReview(reviewId);
3640
+ if (!review) return c.json({ error: "Review not found" }, 404);
3641
+ const mode = parseModeString(review.mode);
3642
+ const headCommit = getHeadCommit(repoRoot);
3643
+ const diffs = getFileDiffs(mode, repoRoot);
3644
+ const result = await updateReviewDiffs(reviewId, diffs, headCommit);
3645
+ return c.json({
3646
+ updated: result.updated,
3647
+ added: result.added,
3648
+ stale: result.stale,
3649
+ fileCount: diffs.length
3650
+ });
3651
+ });
3413
3652
  apiRoutes.delete("/review/:id", async (c) => {
3414
3653
  const reviewId = c.req.param("id");
3415
3654
  const currentReviewId = c.get("currentReviewId");
@@ -3548,7 +3787,7 @@ function readProjectSettings(repoRoot) {
3548
3787
  const settingsPath = join5(repoRoot, ".glassbox", "settings.json");
3549
3788
  try {
3550
3789
  if (existsSync4(settingsPath)) {
3551
- return JSON.parse(readFileSync5(settingsPath, "utf-8"));
3790
+ return JSON.parse(readFileSync6(settingsPath, "utf-8"));
3552
3791
  }
3553
3792
  } catch {
3554
3793
  }
@@ -3571,6 +3810,47 @@ apiRoutes.patch("/project-settings", async (c) => {
3571
3810
  writeProjectSettings(repoRoot, current);
3572
3811
  return c.json(current);
3573
3812
  });
3813
+ apiRoutes.get("/image/:fileId/metadata", async (c) => {
3814
+ const fileId = c.req.param("fileId");
3815
+ const file = await getReviewFile(fileId);
3816
+ if (!file) return c.json({ error: "Not found" }, 404);
3817
+ const repoRoot = c.get("repoRoot");
3818
+ const review = await getReview(file.review_id);
3819
+ if (!review) return c.json({ error: "Review not found" }, 404);
3820
+ const mode = parseModeString(review.mode);
3821
+ const diff = JSON.parse(file.diff_data ?? "{}");
3822
+ const oldPath = diff.oldPath ?? null;
3823
+ const status = diff.status ?? "modified";
3824
+ const oldImage = status !== "added" ? getOldImage(mode, file.file_path, oldPath, repoRoot) : null;
3825
+ const newImage = status !== "deleted" ? getNewImage(mode, file.file_path, repoRoot) : null;
3826
+ const [oldMeta, newMeta] = await Promise.all([
3827
+ oldImage ? extractMetadata(oldImage.data, oldPath ?? file.file_path) : null,
3828
+ newImage ? extractMetadata(newImage.data, file.file_path) : null
3829
+ ]);
3830
+ return c.json({
3831
+ old: oldMeta ? formatMetadataLines(oldMeta) : null,
3832
+ new: newMeta ? formatMetadataLines(newMeta) : null
3833
+ });
3834
+ });
3835
+ apiRoutes.get("/image/:fileId/:side", async (c) => {
3836
+ const fileId = c.req.param("fileId");
3837
+ const side = c.req.param("side");
3838
+ if (side !== "old" && side !== "new") return c.text("Invalid side", 400);
3839
+ const file = await getReviewFile(fileId);
3840
+ if (!file) return c.text("Not found", 404);
3841
+ const repoRoot = c.get("repoRoot");
3842
+ const review = await getReview(file.review_id);
3843
+ if (!review) return c.text("Review not found", 404);
3844
+ const mode = parseModeString(review.mode);
3845
+ const diff = JSON.parse(file.diff_data ?? "{}");
3846
+ const oldPath = diff.oldPath ?? null;
3847
+ const image = side === "old" ? getOldImage(mode, file.file_path, oldPath, repoRoot) : getNewImage(mode, file.file_path, repoRoot);
3848
+ if (!image) return c.text("Image not available", 404);
3849
+ const contentType = getContentType(file.file_path);
3850
+ return new Response(image.data, {
3851
+ headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
3852
+ });
3853
+ });
3574
3854
 
3575
3855
  // src/routes/pages.tsx
3576
3856
  import { Hono as Hono3 } from "hono";
@@ -3644,6 +3924,52 @@ function jsx(tag, props) {
3644
3924
  return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);
3645
3925
  }
3646
3926
 
3927
+ // src/components/imageDiff.tsx
3928
+ function ImageDiff({ file, diff }) {
3929
+ const fileId = file.id;
3930
+ const isAdded = diff.status === "added";
3931
+ const isDeleted = diff.status === "deleted";
3932
+ const hasOld = !isAdded;
3933
+ const hasNew = !isDeleted;
3934
+ const hasComparison = hasOld && hasNew;
3935
+ return /* @__PURE__ */ jsx(
3936
+ "div",
3937
+ {
3938
+ className: "image-diff",
3939
+ "data-file-id": fileId,
3940
+ "data-file-path": file.file_path,
3941
+ "data-has-old": String(hasOld),
3942
+ "data-has-new": String(hasNew),
3943
+ children: [
3944
+ /* @__PURE__ */ jsx("div", { className: "image-diff-panel image-diff-metadata active", "data-panel": "metadata", children: /* @__PURE__ */ jsx("div", { className: "image-metadata-loading", children: "Loading metadata..." }) }),
3945
+ hasComparison && /* @__PURE__ */ jsx("div", { className: "image-diff-panel image-diff-visual", "data-panel": "difference", children: /* @__PURE__ */ jsx("div", { className: "image-visual-canvas", "data-zoomable": "true", children: /* @__PURE__ */ jsx("div", { className: "image-zoom-wrap", children: [
3946
+ /* @__PURE__ */ jsx("img", { className: "image-layer image-layer-old", src: `/api/image/${fileId}/old`, alt: "Old version" }),
3947
+ /* @__PURE__ */ jsx("img", { className: "image-layer image-layer-new image-blend", src: `/api/image/${fileId}/new`, alt: "New version" })
3948
+ ] }) }) }),
3949
+ hasComparison && /* @__PURE__ */ jsx("div", { className: "image-diff-panel image-diff-visual", "data-panel": "slice", children: /* @__PURE__ */ jsx("div", { className: "image-visual-canvas", "data-zoomable": "true", children: [
3950
+ /* @__PURE__ */ jsx("div", { className: "image-zoom-wrap", children: [
3951
+ /* @__PURE__ */ jsx("img", { className: "image-layer image-layer-old", src: `/api/image/${fileId}/old`, alt: "Old version" }),
3952
+ /* @__PURE__ */ jsx("img", { className: "image-layer image-layer-new image-slice-clipped", src: `/api/image/${fileId}/new`, alt: "New version" })
3953
+ ] }),
3954
+ /* @__PURE__ */ jsx("div", { className: "slice-line" }),
3955
+ /* @__PURE__ */ jsx("div", { className: "slice-handle slice-handle-a" }),
3956
+ /* @__PURE__ */ jsx("div", { className: "slice-handle slice-handle-b" })
3957
+ ] }) }),
3958
+ !hasComparison && /* @__PURE__ */ jsx("div", { className: "image-diff-single", children: [
3959
+ /* @__PURE__ */ jsx(
3960
+ "img",
3961
+ {
3962
+ src: `/api/image/${fileId}/${isAdded ? "new" : "old"}`,
3963
+ alt: isAdded ? "New image" : "Deleted image"
3964
+ }
3965
+ ),
3966
+ /* @__PURE__ */ jsx("p", { className: "image-diff-status", children: isAdded ? "New file" : "Deleted file" })
3967
+ ] })
3968
+ ]
3969
+ }
3970
+ );
3971
+ }
3972
+
3647
3973
  // src/components/diffView.tsx
3648
3974
  function DiffView({ file, diff, annotations, mode }) {
3649
3975
  const annotationsByLine = {};
@@ -3657,7 +3983,7 @@ function DiffView({ file, diff, annotations, mode }) {
3657
3983
  /* @__PURE__ */ jsx("span", { className: "file-path", children: diff.filePath }),
3658
3984
  /* @__PURE__ */ jsx("div", { className: "diff-header-actions", children: /* @__PURE__ */ jsx("span", { className: `file-status ${diff.status}`, children: diff.status }) })
3659
3985
  ] }),
3660
- 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 })
3986
+ diff.isBinary && isImageFile(diff.filePath) ? /* @__PURE__ */ jsx(ImageDiff, { file, diff }) : 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 })
3661
3987
  ] });
3662
3988
  }
3663
3989
  function getAnnotations(pair, annotationsByLine) {
@@ -4177,6 +4503,10 @@ function getHistoryScript() {
4177
4503
 
4178
4504
  // src/routes/pages.tsx
4179
4505
  init_queries();
4506
+ var zoomOutSvg = '<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"><line x1="5" y1="12" x2="19" y2="12"/></svg>';
4507
+ var zoomInSvg = '<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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
4508
+ var actualSizeSvg = '<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"><rect x="3" y="3" width="18" height="18" rx="2"/><text x="12" y="15.5" text-anchor="middle" font-size="9" font-weight="bold" fill="currentColor" stroke="none">1:1</text></svg>';
4509
+ var fitSvg = '<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="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>';
4180
4510
  var pageRoutes = new Hono3();
4181
4511
  pageRoutes.get("/", async (c) => {
4182
4512
  const reviewId = c.get("reviewId");
@@ -4223,14 +4553,30 @@ pageRoutes.get("/", async (c) => {
4223
4553
  ] }),
4224
4554
  /* @__PURE__ */ jsx("div", { className: "diff-container", id: "diff-container", style: "display:none" }),
4225
4555
  /* @__PURE__ */ jsx("div", { className: "diff-toolbar", id: "diff-toolbar", style: "display:none", children: [
4226
- /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: [
4227
- /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4228
- /* @__PURE__ */ jsx("button", { className: "segment active", "data-diff-mode": "split", children: "Split" }),
4229
- /* @__PURE__ */ jsx("button", { className: "segment", "data-diff-mode": "unified", children: "Unified" })
4556
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-text", children: [
4557
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: [
4558
+ /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4559
+ /* @__PURE__ */ jsx("button", { className: "segment active", "data-diff-mode": "split", children: "Split" }),
4560
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-diff-mode": "unified", children: "Unified" })
4561
+ ] }),
4562
+ /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "wrap-toggle", children: "Wrap" }),
4563
+ /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "whitespace-toggle", children: "Ignore Whitespace" })
4230
4564
  ] }),
4231
- /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "wrap-toggle", children: "Wrap" })
4565
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "language-btn", children: "Plain Text" }) })
4232
4566
  ] }),
4233
- /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "language-btn", children: "Plain Text" }) })
4567
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-image", style: "display:none", children: [
4568
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4569
+ /* @__PURE__ */ jsx("button", { className: "segment active", "data-image-mode": "metadata", children: "Metadata" }),
4570
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-image-mode": "difference", children: "Difference" }),
4571
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-image-mode": "slice", children: "Slice" })
4572
+ ] }) }),
4573
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: [
4574
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "out", title: "Zoom out", children: raw(zoomOutSvg) }),
4575
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "fit", title: "Fit to view", children: raw(fitSvg) }),
4576
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "actual", title: "Actual size (1:1)", children: raw(actualSizeSvg) }),
4577
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "in", title: "Zoom in", children: raw(zoomInSvg) })
4578
+ ] })
4579
+ ] })
4234
4580
  ] })
4235
4581
  ] })
4236
4582
  ] }) });
@@ -4239,10 +4585,22 @@ pageRoutes.get("/", async (c) => {
4239
4585
  pageRoutes.get("/file/:fileId", async (c) => {
4240
4586
  const fileId = c.req.param("fileId");
4241
4587
  const mode = c.req.query("mode") === "unified" ? "unified" : "split";
4588
+ const ignoreWhitespace = c.req.query("ignoreWhitespace") === "1";
4242
4589
  const file = await getReviewFile(fileId);
4243
4590
  if (!file) return c.text("File not found", 404);
4244
4591
  const annotations = await getAnnotationsForFile(fileId);
4245
- const diff = JSON.parse(file.diff_data ?? "{}");
4592
+ let diff = JSON.parse(file.diff_data ?? "{}");
4593
+ if (ignoreWhitespace) {
4594
+ const repoRoot = c.get("repoRoot");
4595
+ const review = await getReview(file.review_id);
4596
+ if (review) {
4597
+ const reviewMode = parseModeString(review.mode);
4598
+ const regenerated = getSingleFileDiff(reviewMode, file.file_path, repoRoot, "-w");
4599
+ if (regenerated) {
4600
+ diff = regenerated;
4601
+ }
4602
+ }
4603
+ }
4246
4604
  const html = /* @__PURE__ */ jsx(DiffView, { file, diff, annotations, mode });
4247
4605
  return c.html(html.toString());
4248
4606
  });
@@ -4296,14 +4654,30 @@ pageRoutes.get("/review/:reviewId", async (c) => {
4296
4654
  ] }),
4297
4655
  /* @__PURE__ */ jsx("div", { className: "diff-container", id: "diff-container", style: "display:none" }),
4298
4656
  /* @__PURE__ */ jsx("div", { className: "diff-toolbar", id: "diff-toolbar", style: "display:none", children: [
4299
- /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: [
4300
- /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4301
- /* @__PURE__ */ jsx("button", { className: "segment active", "data-diff-mode": "split", children: "Split" }),
4302
- /* @__PURE__ */ jsx("button", { className: "segment", "data-diff-mode": "unified", children: "Unified" })
4657
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-text", children: [
4658
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: [
4659
+ /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4660
+ /* @__PURE__ */ jsx("button", { className: "segment active", "data-diff-mode": "split", children: "Split" }),
4661
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-diff-mode": "unified", children: "Unified" })
4662
+ ] }),
4663
+ /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "wrap-toggle", children: "Wrap" }),
4664
+ /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "whitespace-toggle", children: "Ignore Whitespace" })
4303
4665
  ] }),
4304
- /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "wrap-toggle", children: "Wrap" })
4666
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "language-btn", children: "Plain Text" }) })
4305
4667
  ] }),
4306
- /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: /* @__PURE__ */ jsx("button", { className: "toolbar-btn", id: "language-btn", children: "Plain Text" }) })
4668
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-image", style: "display:none", children: [
4669
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-left", children: /* @__PURE__ */ jsx("div", { className: "segmented-control", children: [
4670
+ /* @__PURE__ */ jsx("button", { className: "segment active", "data-image-mode": "metadata", children: "Metadata" }),
4671
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-image-mode": "difference", children: "Difference" }),
4672
+ /* @__PURE__ */ jsx("button", { className: "segment", "data-image-mode": "slice", children: "Slice" })
4673
+ ] }) }),
4674
+ /* @__PURE__ */ jsx("div", { className: "diff-toolbar-right", children: [
4675
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "out", title: "Zoom out", children: raw(zoomOutSvg) }),
4676
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "fit", title: "Fit to view", children: raw(fitSvg) }),
4677
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "actual", title: "Actual size (1:1)", children: raw(actualSizeSvg) }),
4678
+ /* @__PURE__ */ jsx("button", { className: "image-zoom-btn", "data-zoom-action": "in", title: "Zoom in", children: raw(zoomInSvg) })
4679
+ ] })
4680
+ ] })
4307
4681
  ] })
4308
4682
  ] })
4309
4683
  ] }) });
@@ -4319,10 +4693,10 @@ pageRoutes.get("/history", async (c) => {
4319
4693
 
4320
4694
  // src/server.ts
4321
4695
  function tryServe(fetch2, port) {
4322
- return new Promise((resolve3, reject) => {
4696
+ return new Promise((resolve4, reject) => {
4323
4697
  const server = serve({ fetch: fetch2, port });
4324
4698
  server.on("listening", () => {
4325
- resolve3(port);
4699
+ resolve4(port);
4326
4700
  });
4327
4701
  server.on("error", (err) => {
4328
4702
  if (err.code === "EADDRINUSE") {
@@ -4344,11 +4718,11 @@ async function startServer(port, reviewId, repoRoot, options) {
4344
4718
  const selfDir = dirname(fileURLToPath(import.meta.url));
4345
4719
  const distDir = existsSync5(join6(selfDir, "client", "styles.css")) ? join6(selfDir, "client") : join6(selfDir, "..", "dist", "client");
4346
4720
  app.get("/static/styles.css", (c) => {
4347
- const css = readFileSync6(join6(distDir, "styles.css"), "utf-8");
4721
+ const css = readFileSync7(join6(distDir, "styles.css"), "utf-8");
4348
4722
  return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
4349
4723
  });
4350
4724
  app.get("/static/app.js", (c) => {
4351
- const js = readFileSync6(join6(distDir, "app.global.js"), "utf-8");
4725
+ const js = readFileSync7(join6(distDir, "app.global.js"), "utf-8");
4352
4726
  return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
4353
4727
  });
4354
4728
  app.route("/api", apiRoutes);
@@ -4384,7 +4758,7 @@ async function startServer(port, reviewId, repoRoot, options) {
4384
4758
  }
4385
4759
 
4386
4760
  // src/skills.ts
4387
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
4761
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
4388
4762
  import { join as join7 } from "path";
4389
4763
  var SKILL_VERSION = 1;
4390
4764
  function versionHeader() {
@@ -4397,7 +4771,7 @@ function parseVersionHeader(content) {
4397
4771
  }
4398
4772
  function updateFile(path, content) {
4399
4773
  if (existsSync6(path)) {
4400
- const existing = readFileSync7(path, "utf-8");
4774
+ const existing = readFileSync8(path, "utf-8");
4401
4775
  const version = parseVersionHeader(existing);
4402
4776
  if (version !== null && version >= SKILL_VERSION) {
4403
4777
  return false;
@@ -4501,7 +4875,7 @@ function ensureSkills() {
4501
4875
  }
4502
4876
 
4503
4877
  // src/update-check.ts
4504
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
4878
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
4505
4879
  import { get } from "https";
4506
4880
  import { homedir as homedir3 } from "os";
4507
4881
  import { dirname as dirname2, join as join8 } from "path";
@@ -4512,7 +4886,7 @@ var PACKAGE_NAME = "glassbox";
4512
4886
  function getCurrentVersion() {
4513
4887
  try {
4514
4888
  const dir = dirname2(fileURLToPath2(import.meta.url));
4515
- const pkg = JSON.parse(readFileSync8(join8(dir, "..", "package.json"), "utf-8"));
4889
+ const pkg = JSON.parse(readFileSync9(join8(dir, "..", "package.json"), "utf-8"));
4516
4890
  return pkg.version;
4517
4891
  } catch {
4518
4892
  return "0.0.0";
@@ -4521,7 +4895,7 @@ function getCurrentVersion() {
4521
4895
  function getLastCheckDate() {
4522
4896
  try {
4523
4897
  if (existsSync7(CHECK_FILE)) {
4524
- return readFileSync8(CHECK_FILE, "utf-8").trim();
4898
+ return readFileSync9(CHECK_FILE, "utf-8").trim();
4525
4899
  }
4526
4900
  } catch {
4527
4901
  }
@@ -4538,10 +4912,10 @@ function isFirstUseToday() {
4538
4912
  return last !== today;
4539
4913
  }
4540
4914
  function fetchLatestVersion() {
4541
- return new Promise((resolve3) => {
4915
+ return new Promise((resolve4) => {
4542
4916
  const req = get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { timeout: 5e3 }, (res) => {
4543
4917
  if (res.statusCode !== 200) {
4544
- resolve3(null);
4918
+ resolve4(null);
4545
4919
  return;
4546
4920
  }
4547
4921
  let data = "";
@@ -4550,18 +4924,18 @@ function fetchLatestVersion() {
4550
4924
  });
4551
4925
  res.on("end", () => {
4552
4926
  try {
4553
- resolve3(JSON.parse(data).version);
4927
+ resolve4(JSON.parse(data).version);
4554
4928
  } catch {
4555
- resolve3(null);
4929
+ resolve4(null);
4556
4930
  }
4557
4931
  });
4558
4932
  });
4559
4933
  req.on("error", () => {
4560
- resolve3(null);
4934
+ resolve4(null);
4561
4935
  });
4562
4936
  req.on("timeout", () => {
4563
4937
  req.destroy();
4564
- resolve3(null);
4938
+ resolve4(null);
4565
4939
  });
4566
4940
  });
4567
4941
  }
@@ -4699,7 +5073,7 @@ function parseArgs(argv) {
4699
5073
  port = parseInt(args[++i], 10);
4700
5074
  break;
4701
5075
  case "--data-dir":
4702
- dataDir = resolve2(args[++i]);
5076
+ dataDir = resolve3(args[++i]);
4703
5077
  break;
4704
5078
  case "--resume":
4705
5079
  resume = true;
@@ -4755,7 +5129,7 @@ async function main() {
4755
5129
  console.log("AI service test mode enabled \u2014 using mock AI responses");
4756
5130
  }
4757
5131
  if (debug) {
4758
- console.log(`[debug] Build timestamp: ${"2026-03-16T03:14:46.229Z"}`);
5132
+ console.log(`[debug] Build timestamp: ${"2026-03-16T07:44:26.425Z"}`);
4759
5133
  }
4760
5134
  if (projectDir) {
4761
5135
  process.chdir(projectDir);