repoview 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repoview",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "GitHub-like repo browsing for local Git repositories (Markdown, live reload, broken link scanner).",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "chokidar": "^4.0.3",
47
+ "diff2html": "^3.4.56",
47
48
  "express": "^4.21.2",
48
49
  "github-markdown-css": "^5.8.1",
49
50
  "github-slugger": "^2.0.0",
package/public/app.css CHANGED
@@ -543,6 +543,305 @@ a {
543
543
  }
544
544
  }
545
545
 
546
+ .base-selector {
547
+ font-size: 14px;
548
+ font-weight: 600;
549
+ padding: 5px 10px;
550
+ border-radius: 6px;
551
+ border: 1px solid var(--border);
552
+ background: var(--btn);
553
+ color: var(--text);
554
+ cursor: pointer;
555
+ }
556
+
557
+ .base-selector:hover {
558
+ background: var(--btnHover);
559
+ }
560
+
561
+ .diff-wrap {
562
+ overflow-x: auto;
563
+ -webkit-text-size-adjust: 100%;
564
+ text-size-adjust: 100%;
565
+ }
566
+
567
+ .diff-empty {
568
+ margin: 12px;
569
+ }
570
+
571
+ /* diff2html theme overrides */
572
+ .d2h-wrapper {
573
+ border: none;
574
+ }
575
+
576
+ /* --- File list panel --- */
577
+ .d2h-file-list-wrapper {
578
+ border: 1px solid var(--border);
579
+ border-radius: 6px;
580
+ margin-bottom: 16px;
581
+ overflow: hidden;
582
+ -webkit-text-size-adjust: 100%;
583
+ text-size-adjust: 100%;
584
+ }
585
+
586
+ .d2h-file-list-header {
587
+ display: flex;
588
+ align-items: center;
589
+ gap: 10px;
590
+ padding: 10px 14px;
591
+ background: var(--subtleBg);
592
+ border-bottom: 1px solid var(--border);
593
+ }
594
+
595
+ .d2h-file-list-title {
596
+ font-weight: 600;
597
+ font-size: 14px;
598
+ color: var(--text);
599
+ }
600
+
601
+ .d2h-file-switch {
602
+ display: none;
603
+ }
604
+
605
+ .d2h-file-list {
606
+ list-style: none;
607
+ margin: 0;
608
+ padding: 0;
609
+ }
610
+
611
+ .d2h-file-list-line {
612
+ display: flex;
613
+ align-items: center;
614
+ padding: 8px 14px;
615
+ border-bottom: 1px solid var(--border);
616
+ font-size: 13px;
617
+ }
618
+
619
+ .d2h-file-list-line:last-child {
620
+ border-bottom: none;
621
+ }
622
+
623
+ .d2h-file-list-line:hover {
624
+ background: var(--subtleBg);
625
+ }
626
+
627
+ .d2h-file-name-wrapper {
628
+ display: flex;
629
+ align-items: center;
630
+ gap: 8px;
631
+ width: 100%;
632
+ min-width: 0;
633
+ }
634
+
635
+ .d2h-file-list-line .d2h-file-name {
636
+ color: var(--accent);
637
+ overflow: hidden;
638
+ text-overflow: ellipsis;
639
+ white-space: nowrap;
640
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
641
+ }
642
+
643
+ .d2h-file-list-line .d2h-file-name:hover {
644
+ text-decoration: underline;
645
+ }
646
+
647
+ .d2h-file-stats {
648
+ margin-left: auto;
649
+ display: flex;
650
+ gap: 4px;
651
+ flex-shrink: 0;
652
+ font-size: 12px;
653
+ font-weight: 600;
654
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
655
+ }
656
+
657
+ .d2h-lines-added {
658
+ color: #1a7f37;
659
+ }
660
+
661
+ .d2h-lines-deleted {
662
+ color: #cf222e;
663
+ }
664
+
665
+ .d2h-file-list-line .d2h-icon {
666
+ flex-shrink: 0;
667
+ color: var(--muted);
668
+ }
669
+
670
+ /* --- Individual file diffs --- */
671
+ .d2h-file-wrapper {
672
+ border: 1px solid var(--border);
673
+ border-radius: 6px;
674
+ margin-bottom: 12px;
675
+ overflow: hidden;
676
+ -webkit-text-size-adjust: 100%;
677
+ text-size-adjust: 100%;
678
+ }
679
+
680
+ .d2h-file-header {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 8px;
684
+ padding: 10px 14px;
685
+ background: var(--subtleBg);
686
+ border-bottom: 1px solid var(--border);
687
+ color: var(--text);
688
+ cursor: pointer;
689
+ user-select: none;
690
+ }
691
+
692
+ .d2h-file-header:hover {
693
+ background: var(--btnHover);
694
+ }
695
+
696
+ .d2h-file-header .d2h-file-name-wrapper {
697
+ gap: 8px;
698
+ }
699
+
700
+ .d2h-file-header .d2h-file-name {
701
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
702
+ font-size: 13px;
703
+ font-weight: 600;
704
+ }
705
+
706
+ .d2h-file-header .d2h-tag {
707
+ font-size: 11px;
708
+ padding: 2px 6px;
709
+ border-radius: 4px;
710
+ border: 1px solid var(--border);
711
+ background: var(--btn);
712
+ color: var(--muted);
713
+ }
714
+
715
+ .d2h-file-collapse {
716
+ margin-left: auto;
717
+ display: flex;
718
+ align-items: center;
719
+ font-size: 12px;
720
+ color: var(--muted);
721
+ cursor: pointer;
722
+ padding: 2px 8px;
723
+ border-radius: 4px;
724
+ border: 1px solid var(--border);
725
+ background: var(--btn);
726
+ }
727
+
728
+ .d2h-file-collapse:hover {
729
+ background: var(--btnHover);
730
+ color: var(--text);
731
+ }
732
+
733
+ .d2h-file-collapse,
734
+ .d2h-file-collapse-input {
735
+ display: none;
736
+ }
737
+
738
+ /* Collapse toggle button (injected by JS) */
739
+ .diff-toggle {
740
+ margin-left: auto;
741
+ display: inline-flex;
742
+ align-items: center;
743
+ justify-content: center;
744
+ width: 24px;
745
+ height: 24px;
746
+ border-radius: 4px;
747
+ border: 1px solid var(--border);
748
+ background: var(--btn);
749
+ color: var(--muted);
750
+ cursor: pointer;
751
+ flex-shrink: 0;
752
+ font-size: 14px;
753
+ line-height: 1;
754
+ transition: transform 0.15s ease;
755
+ }
756
+
757
+ .diff-toggle:hover {
758
+ background: var(--btnHover);
759
+ color: var(--text);
760
+ }
761
+
762
+ .diff-toggle[aria-expanded="false"] {
763
+ transform: rotate(-90deg);
764
+ }
765
+
766
+ .d2h-file-diff[hidden] {
767
+ display: none;
768
+ }
769
+
770
+ .d2h-file-diff {
771
+ border-color: var(--border);
772
+ }
773
+
774
+ .d2h-info {
775
+ background: var(--subtleBg);
776
+ color: var(--muted);
777
+ border-color: var(--border);
778
+ }
779
+
780
+ @media (prefers-color-scheme: dark) {
781
+ .d2h-lines-added {
782
+ color: #3fb950;
783
+ }
784
+
785
+ .d2h-lines-deleted {
786
+ color: #f85149;
787
+ }
788
+
789
+ .d2h-code-line,
790
+ .d2h-code-side-line {
791
+ background: var(--panel);
792
+ color: var(--text);
793
+ }
794
+
795
+ .d2h-code-line-ctn {
796
+ color: var(--text);
797
+ }
798
+
799
+ .d2h-file-diff .d2h-del.d2h-change,
800
+ .d2h-file-diff .d2h-del {
801
+ background-color: rgba(248, 81, 73, 0.1);
802
+ }
803
+
804
+ .d2h-file-diff .d2h-ins.d2h-change,
805
+ .d2h-file-diff .d2h-ins {
806
+ background-color: rgba(63, 185, 80, 0.1);
807
+ }
808
+
809
+ .d2h-del .d2h-code-line-ctn {
810
+ background-color: rgba(248, 81, 73, 0.15);
811
+ }
812
+
813
+ .d2h-ins .d2h-code-line-ctn {
814
+ background-color: rgba(63, 185, 80, 0.15);
815
+ }
816
+
817
+ .d2h-code-linenumber {
818
+ background: var(--panel);
819
+ color: var(--muted);
820
+ border-color: var(--border);
821
+ }
822
+
823
+ .d2h-del .d2h-code-linenumber {
824
+ background-color: rgba(248, 81, 73, 0.1);
825
+ border-color: var(--border);
826
+ }
827
+
828
+ .d2h-ins .d2h-code-linenumber {
829
+ background-color: rgba(63, 185, 80, 0.1);
830
+ border-color: var(--border);
831
+ }
832
+ }
833
+
834
+ /* Normalize diff2html font sizes to prevent iOS text inflation */
835
+ .d2h-code-linenumber,
836
+ .d2h-code-line-ctn,
837
+ .d2h-code-line-prefix {
838
+ font-size: 13px !important;
839
+ }
840
+
841
+ .d2h-tag {
842
+ font-size: 11px;
843
+ }
844
+
546
845
  @media (max-width: 560px) {
547
846
  .file-table {
548
847
  min-width: 0;
@@ -561,4 +860,39 @@ a {
561
860
  .meta-menu {
562
861
  display: inline-block;
563
862
  }
863
+
864
+ /* Diff mobile overrides */
865
+ .d2h-file-list-wrapper {
866
+ margin-bottom: 12px;
867
+ }
868
+
869
+ .d2h-file-list-line {
870
+ padding: 6px 10px;
871
+ }
872
+
873
+ .d2h-file-header {
874
+ padding: 8px 10px;
875
+ gap: 6px;
876
+ }
877
+
878
+ .d2h-file-header .d2h-file-name {
879
+ font-size: 12px;
880
+ }
881
+
882
+ .d2h-code-linenumber {
883
+ padding: 0 4px !important;
884
+ min-width: 28px;
885
+ }
886
+
887
+ .d2h-code-line-ctn {
888
+ font-size: 12px !important;
889
+ }
890
+
891
+ .d2h-code-line-prefix {
892
+ font-size: 12px !important;
893
+ }
894
+
895
+ .d2h-file-wrapper {
896
+ margin-bottom: 10px;
897
+ }
564
898
  }
package/public/app.js CHANGED
@@ -106,7 +106,12 @@ async function renderMermaid() {
106
106
  try {
107
107
  const mod = await import("/static/vendor/mermaid/mermaid.esm.min.mjs");
108
108
  const mermaid = mod.default ?? mod.mermaid ?? mod;
109
- mermaid.initialize?.({ startOnLoad: false, securityLevel: "strict" });
109
+ const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
110
+ mermaid.initialize?.({
111
+ startOnLoad: false,
112
+ securityLevel: "strict",
113
+ theme: isDark ? "dark" : "default",
114
+ });
110
115
  if (typeof mermaid.run === "function") {
111
116
  await mermaid.run({ nodes });
112
117
  }
@@ -175,9 +180,52 @@ function initTimezoneToggle() {
175
180
  update();
176
181
  }
177
182
 
183
+ function initDiffCollapse() {
184
+ const wrappers = document.querySelectorAll(".d2h-file-wrapper");
185
+ if (!wrappers.length) return;
186
+
187
+ for (const wrapper of wrappers) {
188
+ const header = wrapper.querySelector(".d2h-file-header");
189
+ const diff = wrapper.querySelector(".d2h-file-diff");
190
+ if (!header || !diff) continue;
191
+
192
+ const toggle = document.createElement("button");
193
+ toggle.className = "diff-toggle";
194
+ toggle.type = "button";
195
+ toggle.setAttribute("aria-expanded", "true");
196
+ toggle.setAttribute("aria-label", "Toggle file diff");
197
+ toggle.textContent = "\u25BE";
198
+ header.appendChild(toggle);
199
+
200
+ header.addEventListener("click", (e) => {
201
+ if (e.target.closest("a")) return;
202
+ const expanded = toggle.getAttribute("aria-expanded") === "true";
203
+ toggle.setAttribute("aria-expanded", String(!expanded));
204
+ toggle.textContent = expanded ? "\u25B8" : "\u25BE";
205
+ diff.hidden = expanded;
206
+ });
207
+ }
208
+ }
209
+
210
+ function initBaseSelector() {
211
+ const sel = document.getElementById("base-selector");
212
+ if (!sel) return;
213
+ sel.addEventListener("change", () => {
214
+ const u = new URL(location.href);
215
+ if (sel.value === "HEAD") {
216
+ u.searchParams.delete("base");
217
+ } else {
218
+ u.searchParams.set("base", sel.value);
219
+ }
220
+ location.href = u.pathname + u.search;
221
+ });
222
+ }
223
+
178
224
  window.addEventListener("load", () => {
179
- preserveQueryParamsOnInternalLinks(["ignored", "watch"]);
225
+ preserveQueryParamsOnInternalLinks(["ignored", "watch", "base"]);
180
226
  renderMath();
181
227
  renderMermaid();
182
228
  initTimezoneToggle();
229
+ initBaseSelector();
230
+ initDiffCollapse();
183
231
  });
package/src/server.js CHANGED
@@ -11,9 +11,11 @@ import mime from "mime-types";
11
11
  import { createMarkdownRenderer } from "./markdown.js";
12
12
  import { loadGitIgnoreMatcher } from "./gitignore.js";
13
13
  import { createRepoLinkScanner } from "./linkcheck.js";
14
+ import diff2html from "diff2html";
14
15
  import {
15
16
  escapeHtml,
16
17
  renderBrokenLinksPage,
18
+ renderDiffPage,
17
19
  renderErrorPage,
18
20
  renderFilePage,
19
21
  renderTreePage,
@@ -93,6 +95,51 @@ function renderCsvTable(rows, escFn) {
93
95
  return `<div class="csv-table-wrap"><table class="csv-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
94
96
  }
95
97
 
98
+ function execGit(repoRootReal, args, maxBytes = 1024 * 1024) {
99
+ return new Promise((resolve) => {
100
+ const child = spawn("git", args, { cwd: repoRootReal });
101
+ let out = "";
102
+ let size = 0;
103
+ let killed = false;
104
+ child.stdout.on("data", (chunk) => {
105
+ size += chunk.length;
106
+ if (size > maxBytes) {
107
+ if (!killed) { killed = true; child.kill(); }
108
+ return;
109
+ }
110
+ out += String(chunk);
111
+ });
112
+ child.on("close", (code) => {
113
+ if (killed) return resolve({ output: out, tooLarge: true, code });
114
+ resolve({ output: code === 0 ? out.trim() : null, tooLarge: false, code });
115
+ });
116
+ child.on("error", () => resolve({ output: null, tooLarge: false, code: -1 }));
117
+ });
118
+ }
119
+
120
+ function validateGitRef(ref) {
121
+ if (!ref || typeof ref !== "string") return false;
122
+ return /^[a-zA-Z0-9_.\/\-~^]+$/.test(ref);
123
+ }
124
+
125
+ async function getGitBranches(repoRootReal) {
126
+ const { output } = await execGit(repoRootReal, ["branch", "--format=%(refname:short)"]);
127
+ if (!output) return [];
128
+ return output.split("\n").filter(Boolean);
129
+ }
130
+
131
+ async function getGitTags(repoRootReal) {
132
+ const { output } = await execGit(repoRootReal, ["tag", "-l"]);
133
+ if (!output) return [];
134
+ return output.split("\n").filter(Boolean);
135
+ }
136
+
137
+ async function getGitDiffRaw(repoRootReal, base) {
138
+ const maxBytes = 512 * 1024;
139
+ const { output, tooLarge } = await execGit(repoRootReal, ["diff", base], maxBytes);
140
+ return { raw: output || "", tooLarge };
141
+ }
142
+
96
143
  async function getGitInfo(repoRootReal) {
97
144
  const gitDir = path.join(repoRootReal, ".git");
98
145
  try {
@@ -102,20 +149,12 @@ async function getGitInfo(repoRootReal) {
102
149
  return { branch: null, commit: null };
103
150
  }
104
151
 
105
- const execGit = async (args) => {
106
- return await new Promise((resolve) => {
107
- const child = spawn("git", args, { cwd: repoRootReal });
108
- let out = "";
109
- child.stdout.on("data", (chunk) => (out += String(chunk)));
110
- child.on("close", (code) => resolve(code === 0 ? out.trim() : null));
111
- child.on("error", () => resolve(null));
112
- });
113
- };
114
-
115
- const [branch, commit] = await Promise.all([
116
- execGit(["rev-parse", "--abbrev-ref", "HEAD"]),
117
- execGit(["rev-parse", "HEAD"]),
152
+ const [branchResult, commitResult] = await Promise.all([
153
+ execGit(repoRootReal, ["rev-parse", "--abbrev-ref", "HEAD"]),
154
+ execGit(repoRootReal, ["rev-parse", "HEAD"]),
118
155
  ]);
156
+ const branch = branchResult.output;
157
+ const commit = commitResult.output;
119
158
  return { branch: branch && branch !== "HEAD" ? branch : branch, commit };
120
159
  }
121
160
 
@@ -144,13 +183,20 @@ async function safeRealpath(rootReal, requestPath) {
144
183
  }
145
184
 
146
185
  async function statSafe(p, { followSymlinks = true } = {}) {
147
- const stat = followSymlinks ? await fs.stat(p) : await fs.lstat(p);
148
- return {
149
- isFile: stat.isFile(),
150
- isDir: stat.isDirectory(),
151
- size: stat.size,
152
- mtimeMs: stat.mtimeMs,
153
- };
186
+ try {
187
+ const stat = followSymlinks ? await fs.stat(p) : await fs.lstat(p);
188
+ return {
189
+ isFile: stat.isFile(),
190
+ isDir: stat.isDirectory(),
191
+ size: stat.size,
192
+ mtimeMs: stat.mtimeMs,
193
+ };
194
+ } catch (e) {
195
+ if (e.code === "EACCES" || e.code === "EPERM") {
196
+ return null;
197
+ }
198
+ throw e;
199
+ }
154
200
  }
155
201
 
156
202
  function formatBytes(bytes) {
@@ -249,6 +295,12 @@ export async function startServer({ repoRoot, host, port, watch }) {
249
295
  fallthrough: false,
250
296
  }),
251
297
  );
298
+ app.use(
299
+ "/static/vendor/diff2html",
300
+ express.static(path.join(resolvePackageDir("diff2html"), "bundles", "css"), {
301
+ fallthrough: false,
302
+ }),
303
+ );
252
304
 
253
305
  app.use((req, res, next) => {
254
306
  if (!req.path.startsWith("/static/")) res.setHeader("Cache-Control", "no-store");
@@ -316,6 +368,63 @@ export async function startServer({ repoRoot, host, port, watch }) {
316
368
  res.on("close", () => clearInterval(interval));
317
369
  });
318
370
 
371
+ app.get("/diff", async (req, res) => {
372
+ try {
373
+ const base = req.query.base || "HEAD";
374
+ if (!validateGitRef(base)) {
375
+ const err = new Error("Invalid base ref");
376
+ err.statusCode = 400;
377
+ throw err;
378
+ }
379
+
380
+ if (!gitInfo.commit) {
381
+ const err = new Error("Not a git repository");
382
+ err.statusCode = 400;
383
+ throw err;
384
+ }
385
+
386
+ const query = new URLSearchParams();
387
+ if (req.query.watch === "0") query.set("watch", "0");
388
+ if (req.query.ignored === "1") query.set("ignored", "1");
389
+ if (base !== "HEAD") query.set("base", base);
390
+ const querySuffix = query.toString() ? `?${query.toString()}` : "";
391
+
392
+ const [branches, tags, diffResult] = await Promise.all([
393
+ getGitBranches(repoRootReal),
394
+ getGitTags(repoRootReal),
395
+ getGitDiffRaw(repoRootReal, base),
396
+ ]);
397
+
398
+ let diffHtml = "";
399
+ if (!diffResult.tooLarge && diffResult.raw) {
400
+ diffHtml = diff2html.html(diffResult.raw, {
401
+ outputFormat: "line-by-line",
402
+ drawFileList: true,
403
+ });
404
+ }
405
+
406
+ res.status(200).send(
407
+ renderDiffPage({
408
+ title: `${repoName} · Diff`,
409
+ repoName,
410
+ gitInfo,
411
+ relPathPosix: "",
412
+ querySuffix,
413
+ base,
414
+ branches,
415
+ tags,
416
+ diffHtml,
417
+ tooLarge: diffResult.tooLarge,
418
+ empty: !diffResult.raw,
419
+ }),
420
+ );
421
+ } catch (e) {
422
+ res
423
+ .status(e.statusCode || 500)
424
+ .send(renderErrorPage({ title: "Error", message: e.message }));
425
+ }
426
+ });
427
+
319
428
  app.get(["/tree/*", "/tree"], async (req, res) => {
320
429
  try {
321
430
  const showIgnored = req.query.ignored === "1";
@@ -336,43 +445,61 @@ export async function startServer({ repoRoot, host, port, watch }) {
336
445
  toPosixPath(stripped),
337
446
  )}${toggleIgnoredSuffix}`;
338
447
  const st = await statSafe(resolved);
448
+ if (st === null) {
449
+ const err = new Error("Permission denied");
450
+ err.statusCode = 403;
451
+ throw err;
452
+ }
339
453
  if (st.isFile)
340
454
  return res.redirect(
341
455
  `/blob/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
342
456
  );
343
457
 
344
- const entries = await fs.readdir(resolved, { withFileTypes: true });
458
+ let entries;
459
+ try {
460
+ entries = await fs.readdir(resolved, { withFileTypes: true });
461
+ } catch (e) {
462
+ if (e.code === "EACCES" || e.code === "EPERM") {
463
+ const err = new Error("Permission denied");
464
+ err.statusCode = 403;
465
+ throw err;
466
+ }
467
+ throw e;
468
+ }
345
469
  const readmeEntry = entries.find(
346
470
  (e) =>
347
471
  e.isFile() &&
348
472
  /^readme(?:\.(?:md|markdown|mdown|mkd|mkdn))?$/i.test(e.name),
349
473
  );
350
- const rows = await Promise.all(
351
- entries
352
- .filter((e) => {
353
- if (e.name === ".git") return false;
354
- if (showIgnored) return true;
355
- const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
356
- return !ignoreMatcher.ignores(relPosix, { isDir: e.isDirectory() });
357
- })
358
- .map(async (e) => {
359
- const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
360
- const full = path.join(resolved, e.name);
361
- const info = await statSafe(full, { followSymlinks: false });
362
- const isDir = e.isDirectory();
363
- const href = isDir
364
- ? `/tree/${encodePathForUrl(relPosix)}${querySuffix}`
365
- : `/blob/${encodePathForUrl(relPosix)}${querySuffix}`;
366
- return {
367
- name: e.name,
368
- isDir,
369
- href,
370
- size: isDir ? "" : formatBytes(info.size),
371
- mtime: formatDate(info.mtimeMs),
372
- mtimeMs: info.mtimeMs,
373
- };
374
- }),
375
- );
474
+ const rows = (
475
+ await Promise.all(
476
+ entries
477
+ .filter((e) => {
478
+ if (e.name === ".git") return false;
479
+ if (showIgnored) return true;
480
+ const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
481
+ return !ignoreMatcher.ignores(relPosix, { isDir: e.isDirectory() });
482
+ })
483
+ .map(async (e) => {
484
+ const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
485
+ const full = path.join(resolved, e.name);
486
+ const info = await statSafe(full, { followSymlinks: false });
487
+ if (info === null) return null;
488
+ const isDir = e.isDirectory();
489
+ const href = isDir
490
+ ? `/tree/${encodePathForUrl(relPosix)}${querySuffix}`
491
+ : `/blob/${encodePathForUrl(relPosix)}${querySuffix}`;
492
+ return {
493
+ name: e.name,
494
+ isDir,
495
+ href,
496
+ size: isDir ? "" : formatBytes(info.size),
497
+ mtime: formatDate(info.mtimeMs),
498
+ mtimeMs: info.mtimeMs,
499
+ };
500
+ }),
501
+ )
502
+ ).filter((row) => row !== null);
376
503
 
377
504
  rows.sort((a, b) => {
378
505
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
@@ -439,6 +566,11 @@ export async function startServer({ repoRoot, host, port, watch }) {
439
566
  toPosixPath(stripped),
440
567
  )}${toggleIgnoredSuffix}`;
441
568
  const st = await statSafe(resolved);
569
+ if (st === null) {
570
+ const err = new Error("Permission denied");
571
+ err.statusCode = 403;
572
+ throw err;
573
+ }
442
574
  if (st.isDir)
443
575
  return res.redirect(
444
576
  `/tree/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
@@ -563,6 +695,11 @@ export async function startServer({ repoRoot, host, port, watch }) {
563
695
  const p = req.params[0] ?? "";
564
696
  const { resolved } = await safeRealpath(repoRootReal, p);
565
697
  const st = await statSafe(resolved);
698
+ if (st === null) {
699
+ const err = new Error("Permission denied");
700
+ err.statusCode = 403;
701
+ throw err;
702
+ }
566
703
  if (!st.isFile) {
567
704
  const err = new Error("Not a file");
568
705
  err.statusCode = 400;
@@ -589,6 +726,10 @@ export async function startServer({ repoRoot, host, port, watch }) {
589
726
  /(^|[/\\])node_modules([/\\]|$)/,
590
727
  ],
591
728
  ignoreInitial: true,
729
+ ignorePermissionErrors: true,
730
+ });
731
+ watcher.on("error", () => {
732
+ // Silently ignore watch errors (e.g., permission denied)
592
733
  });
593
734
  let pending = null;
594
735
  watcher.on("all", () => {
package/src/views.js CHANGED
@@ -105,9 +105,12 @@ function renderMetaMenu({ gitInfo, brokenLinks, querySuffix, toggleIgnoredHref,
105
105
  const ignoredHref = toggleIgnoredHref || "#";
106
106
  const ignoredLabel = showIgnored ? "Hide ignored files" : "Show ignored files";
107
107
 
108
+ const diffHref = `/diff${querySuffix || ""}`;
109
+
108
110
  return `<details class="meta-menu">
109
111
  <summary class="pill link" aria-label="More">More</summary>
110
112
  <div class="menu-panel" role="menu">
113
+ <a class="menu-item link" href="${diffHref}" role="menuitem">Diff view</a>
111
114
  <a class="menu-item link" href="${brokenHref}" role="menuitem">${escapeHtml(brokenLabel)}</a>
112
115
  <a class="menu-item link" data-no-preserve="ignored" href="${ignoredHref}" role="menuitem">${escapeHtml(
113
116
  ignoredLabel,
@@ -153,6 +156,7 @@ function pageTemplateWithLinks({
153
156
  <span class="pill">${branch}</span>
154
157
  ${commit ? `<span class="pill mono meta-commit">${commit}</span>` : ""}
155
158
  <span class="meta-actions">
159
+ <a class="pill link" href="/diff${querySuffix || ""}">Diff</a>
156
160
  ${brokenPill}
157
161
  ${ignoredPill}
158
162
  </span>
@@ -277,6 +281,89 @@ export function renderFilePage({
277
281
  });
278
282
  }
279
283
 
284
+ export function renderDiffPage({
285
+ title,
286
+ repoName,
287
+ gitInfo,
288
+ relPathPosix,
289
+ querySuffix,
290
+ base,
291
+ branches,
292
+ tags,
293
+ diffHtml,
294
+ tooLarge,
295
+ empty,
296
+ }) {
297
+ const branchOptions = branches
298
+ .map((b) => {
299
+ const sel = b === base ? " selected" : "";
300
+ return `<option value="${escapeHtml(b)}"${sel}>${escapeHtml(b)}</option>`;
301
+ })
302
+ .join("\n");
303
+ const tagOptions = tags
304
+ .map((t) => {
305
+ const sel = t === base ? " selected" : "";
306
+ return `<option value="${escapeHtml(t)}"${sel}>${escapeHtml(t)}</option>`;
307
+ })
308
+ .join("\n");
309
+ const headSelected = base === "HEAD" ? " selected" : "";
310
+
311
+ const selector = `<select id="base-selector" class="base-selector">
312
+ <option value="HEAD"${headSelected}>HEAD</option>
313
+ ${branches.length ? `<optgroup label="Branches">${branchOptions}</optgroup>` : ""}
314
+ ${tags.length ? `<optgroup label="Tags">${tagOptions}</optgroup>` : ""}
315
+ </select>`;
316
+
317
+ let content = "";
318
+ if (tooLarge) {
319
+ content = `<div class="diff-empty note">Diff output exceeded 512KB and was truncated. Try narrowing the comparison range.</div>`;
320
+ } else if (empty) {
321
+ content = `<div class="diff-empty note">No changes found.</div>`;
322
+ } else {
323
+ content = diffHtml;
324
+ }
325
+
326
+ const body = `<section class="panel">
327
+ <div class="panel-title">
328
+ <span>Compare working tree against</span>
329
+ ${selector}
330
+ <span class="spacer"></span>
331
+ <a class="btn" href="/tree/${querySuffix || ""}">Back</a>
332
+ </div>
333
+ <div class="diff-wrap">
334
+ ${content}
335
+ </div>
336
+ </section>`;
337
+
338
+ const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
339
+ const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
340
+ return `<!doctype html>
341
+ <html lang="en">
342
+ <head>
343
+ <meta charset="utf-8" />
344
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
345
+ <title>${escapeHtml(title)}</title>
346
+ <link rel="stylesheet" href="/static/vendor/diff2html/diff2html.min.css" />
347
+ <link rel="stylesheet" href="/static/app.css" />
348
+ </head>
349
+ <body>
350
+ <header class="topbar">
351
+ <div class="topbar-row">
352
+ <a class="brand" href="/tree/${querySuffix || ""}">${escapeHtml(repoName)}</a>
353
+ <div class="meta">
354
+ <span class="pill">${branch}</span>
355
+ ${commit ? `<span class="pill mono">${commit}</span>` : ""}
356
+ </div>
357
+ </div>
358
+ </header>
359
+ <main class="container">
360
+ ${body}
361
+ </main>
362
+ <script type="module" src="/static/app.js"></script>
363
+ </body>
364
+ </html>`;
365
+ }
366
+
280
367
  export function renderErrorPage({ title, message }) {
281
368
  return `<!doctype html>
282
369
  <html lang="en">