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 +2 -1
- package/public/app.css +334 -0
- package/public/app.js +50 -2
- package/src/server.js +188 -47
- package/src/views.js +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repoview",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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 =
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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">
|