gitroster 0.2.0__py3-none-any.whl
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.
- gitroster/__init__.py +1 -0
- gitroster/__main__.py +4 -0
- gitroster/agent.py +157 -0
- gitroster/cli.py +43 -0
- gitroster/demo.py +140 -0
- gitroster/deps.py +207 -0
- gitroster/export.py +40 -0
- gitroster/opener.py +218 -0
- gitroster/pathscan.py +34 -0
- gitroster/procs.py +179 -0
- gitroster/repoinfo.py +228 -0
- gitroster/scanner.py +454 -0
- gitroster/server.py +234 -0
- gitroster/static/icon.svg +12 -0
- gitroster/static/index.html +833 -0
- gitroster/store.py +80 -0
- gitroster-0.2.0.dist-info/METADATA +138 -0
- gitroster-0.2.0.dist-info/RECORD +22 -0
- gitroster-0.2.0.dist-info/WHEEL +5 -0
- gitroster-0.2.0.dist-info/entry_points.txt +2 -0
- gitroster-0.2.0.dist-info/licenses/LICENSE +9 -0
- gitroster-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>gitroster</title>
|
|
7
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
8
|
+
<script>try { const t = localStorage.getItem('gr-theme'); if (t === 'dark' || t === 'light') document.documentElement.dataset.theme = t; } catch {}</script>
|
|
9
|
+
<style>
|
|
10
|
+
:root{--bg:#fff;--fg:#111;--mute:#777;--line:#e6e6e6;--card:#fafafa;--warn:#b45309;--ok:#15803d;--bar:#111;color-scheme:light}
|
|
11
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#111;--fg:#eee;--mute:#8a8a8a;--line:#262626;--card:#181818;--warn:#f59e0b;--ok:#4ade80;--bar:#eee;--logo:invert(1);color-scheme:dark}}
|
|
12
|
+
:root[data-theme=dark]{--bg:#111;--fg:#eee;--mute:#8a8a8a;--line:#262626;--card:#181818;--warn:#f59e0b;--ok:#4ade80;--bar:#eee;--logo:invert(1);color-scheme:dark}
|
|
13
|
+
*{box-sizing:border-box}
|
|
14
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,system-ui,sans-serif}
|
|
15
|
+
main{max-width:920px;margin:0 auto;padding:32px 16px 80px}
|
|
16
|
+
header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px}
|
|
17
|
+
.logo{filter:var(--logo,none);width:24px;height:24px;border-radius:6px}
|
|
18
|
+
h1{font-size:18px;margin:0;font-weight:600;letter-spacing:-.01em}
|
|
19
|
+
.sub{color:var(--mute);font-size:13px}
|
|
20
|
+
.sp{flex:1}
|
|
21
|
+
button,input,select,textarea{font:inherit;color:inherit}
|
|
22
|
+
button{background:none;border:1px solid var(--line);border-radius:6px;padding:3px 10px;cursor:pointer}
|
|
23
|
+
button:hover{border-color:var(--fg)}
|
|
24
|
+
button:disabled{opacity:.5;cursor:default}
|
|
25
|
+
input,select,textarea{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:5px 10px}
|
|
26
|
+
input{width:180px}
|
|
27
|
+
nav{display:flex;gap:18px;border-bottom:1px solid var(--line);margin-bottom:12px}
|
|
28
|
+
nav a{padding:6px 0;color:var(--mute);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
29
|
+
nav a.on{color:var(--fg);border-color:var(--fg)}
|
|
30
|
+
.bar{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px}
|
|
31
|
+
.chip{border-radius:12px;padding:1px 10px;font-size:12px;color:var(--mute)}
|
|
32
|
+
.chip.on{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
|
33
|
+
.row{border-top:1px solid var(--line);padding:12px 0}
|
|
34
|
+
.row:last-child{border-bottom:1px solid var(--line)}
|
|
35
|
+
.top{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;cursor:pointer}
|
|
36
|
+
.name{font-weight:600}
|
|
37
|
+
.pin{border:0;padding:0 2px;color:var(--mute);cursor:pointer;background:none}
|
|
38
|
+
.pin.on{color:var(--fg)}
|
|
39
|
+
.path,.mono{font-family:ui-monospace,monospace;font-size:12px;color:var(--mute)}
|
|
40
|
+
.tag{font-size:11px;border:1px solid var(--line);border-radius:10px;padding:0 7px;color:var(--mute);white-space:nowrap}
|
|
41
|
+
.tag.port{color:var(--ok);border-color:var(--ok)}
|
|
42
|
+
.tag.warn{color:var(--warn);border-color:var(--warn)}
|
|
43
|
+
.meta{color:var(--mute);font-size:12px}
|
|
44
|
+
.detail{display:none;margin-top:10px;padding:12px;background:var(--card);border-radius:6px}
|
|
45
|
+
.row.open .detail{display:block}
|
|
46
|
+
.detail .line{margin:6px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
|
47
|
+
.k{color:var(--mute);width:72px;flex:none}
|
|
48
|
+
code{font-family:ui-monospace,monospace;font-size:12px;background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:1px 5px;cursor:copy;word-break:break-all}
|
|
49
|
+
.go{padding:0 8px;font-size:12px}
|
|
50
|
+
textarea{width:100%;min-height:54px;resize:vertical;font-size:13px}
|
|
51
|
+
.ai{white-space:pre-wrap;font-size:13px;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:10px;margin-top:6px}
|
|
52
|
+
.log{white-space:pre-wrap;font:11px/1.4 ui-monospace,monospace;color:var(--mute);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;max-height:160px;overflow:auto;margin-top:6px}
|
|
53
|
+
table{width:100%;border-collapse:collapse}
|
|
54
|
+
td{padding:8px 8px 8px 0;border-top:1px solid var(--line);vertical-align:top}
|
|
55
|
+
tr:last-child td{border-bottom:1px solid var(--line)}
|
|
56
|
+
td.r{text-align:right;white-space:nowrap}
|
|
57
|
+
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--mute);font-weight:500;margin:28px 0 6px}
|
|
58
|
+
.stats{display:flex;gap:28px;flex-wrap:wrap;margin:4px 0 8px}
|
|
59
|
+
.stats b{display:block;font-size:20px;font-weight:600;letter-spacing:-.02em}
|
|
60
|
+
.stats span{color:var(--mute);font-size:12px}
|
|
61
|
+
.empty{color:var(--mute);padding:12px 0}
|
|
62
|
+
.empty-hero{padding:8px 0}.empty-hero .lead{font-size:16px;margin:0 0 4px}
|
|
63
|
+
.chip.more{border-style:dashed}
|
|
64
|
+
a{color:inherit}
|
|
65
|
+
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:6px 14px;border-radius:6px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
|
66
|
+
#toast.on{opacity:1}
|
|
67
|
+
.hide{display:none}
|
|
68
|
+
#page,#settings{position:fixed;inset:0;background:var(--bg);z-index:10;display:none;overflow-y:scroll}
|
|
69
|
+
html{overflow-y:scroll}
|
|
70
|
+
#page.on{display:block}
|
|
71
|
+
.pm{max-width:var(--pw,920px);margin:0 auto;padding:32px 16px 80px}
|
|
72
|
+
.ph{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:20px}
|
|
73
|
+
#ptags .tag{margin-left:4px}
|
|
74
|
+
#plink{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;margin-left:6px;padding:0;border:1px solid var(--line);border-radius:50%;color:var(--fg);text-decoration:none;vertical-align:middle}
|
|
75
|
+
#plink:hover{border-color:var(--fg)}
|
|
76
|
+
#plink.hide{display:none}
|
|
77
|
+
#sback{margin-bottom:10px}
|
|
78
|
+
#sback.hide{display:none}
|
|
79
|
+
#ptabs{display:flex;gap:18px;border-bottom:1px solid var(--line);margin-bottom:16px}
|
|
80
|
+
#ptabs a{padding:6px 0;color:var(--mute);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
81
|
+
#ptabs a.on{color:var(--fg);border-color:var(--fg)}
|
|
82
|
+
.x{width:30px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:50%}
|
|
83
|
+
a.ibw{display:inline-flex}
|
|
84
|
+
#popen{display:inline-flex;gap:6px}
|
|
85
|
+
.line .ib,.ib.sm{width:28px;height:28px}
|
|
86
|
+
.ib.xs{width:22px;height:22px;border-radius:6px}
|
|
87
|
+
.ib.xs svg{width:13px;height:13px}
|
|
88
|
+
.top .go,.top .ib{align-self:center}
|
|
89
|
+
.ai-btn{color:var(--mute)}
|
|
90
|
+
.ai-btn.on{color:var(--fg);border-color:var(--fg)}
|
|
91
|
+
.ai-btn.stale{color:var(--warn);border-color:var(--warn)}
|
|
92
|
+
.ai-btn.busy svg{animation:spin 1s linear infinite}
|
|
93
|
+
.about ul{margin:6px 0 10px;padding-left:18px;color:var(--mute);font-size:13px}
|
|
94
|
+
.about li{margin:3px 0}
|
|
95
|
+
.about a{color:var(--fg)}
|
|
96
|
+
.ai .line{margin:4px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:nowrap}
|
|
97
|
+
.ai .v{flex:1;min-width:0}
|
|
98
|
+
.aihead{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
|
99
|
+
.aihead svg{color:var(--fg)}
|
|
100
|
+
.aifoot{margin-top:6px}
|
|
101
|
+
.opt.dim{opacity:.55}
|
|
102
|
+
.hint{margin:-2px 0 6px 24px}
|
|
103
|
+
.ib{width:32px;height:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--fg)}
|
|
104
|
+
.ib:disabled svg{animation:spin 1s linear infinite}
|
|
105
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
106
|
+
button.primary{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
|
107
|
+
button.primary:hover{opacity:.85}
|
|
108
|
+
#settings{background:rgba(0,0,0,.45);align-items:flex-start;justify-content:center;padding:10vh 16px}
|
|
109
|
+
#settings.on{display:flex}
|
|
110
|
+
#settings .card{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:20px;width:100%;max-width:460px}
|
|
111
|
+
.opt{display:flex;gap:8px;align-items:center;padding:4px 0;color:var(--fg);font-size:14px}
|
|
112
|
+
.opt input{width:auto}
|
|
113
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
114
|
+
.segopt{margin:0;cursor:pointer}
|
|
115
|
+
.segopt input{position:absolute;opacity:0;width:0;height:0}
|
|
116
|
+
.segopt span{display:block;padding:5px 16px;font-size:13px;color:var(--mute);border-left:1px solid var(--line)}
|
|
117
|
+
.segopt:first-child span{border-left:0}
|
|
118
|
+
.segopt input:checked+span{background:var(--fg);color:var(--bg)}
|
|
119
|
+
.segopt input:focus-visible+span{outline:2px solid var(--fg);outline-offset:-2px}
|
|
120
|
+
.ov{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:32px;align-items:start}
|
|
121
|
+
@media(max-width:760px){.ov{grid-template-columns:1fr}}
|
|
122
|
+
.ov aside h2:first-child{margin-top:0}
|
|
123
|
+
.lbar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--line);gap:1px}.lbar i{display:block}
|
|
124
|
+
.lg{display:flex;justify-content:space-between;gap:8px;font-size:12px;padding:1px 0}
|
|
125
|
+
.lg .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
|
126
|
+
.split{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:16px;align-items:start}
|
|
127
|
+
.split.hist{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
|
|
128
|
+
@media(max-width:820px){.split,.split.hist{grid-template-columns:1fr}}
|
|
129
|
+
.panel{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--bg)}
|
|
130
|
+
.panel .bar2{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--card);border-bottom:1px solid var(--line);font-size:13px;min-height:38px}
|
|
131
|
+
.panel .bar2 .sp{flex:1}
|
|
132
|
+
.crumbs{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mute)}
|
|
133
|
+
.ent{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;border-top:1px solid var(--line);font-size:13px}
|
|
134
|
+
.ent:first-child{border-top:0}
|
|
135
|
+
.ent:hover{background:var(--card)}
|
|
136
|
+
.ent.sel{background:var(--card);box-shadow:inset 2px 0 var(--fg)}
|
|
137
|
+
.ent .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
138
|
+
.ic{flex:none;color:var(--mute)}
|
|
139
|
+
.ent.dir .ic{color:var(--fg)}
|
|
140
|
+
.list{max-height:70vh;overflow:auto}
|
|
141
|
+
.gl{overflow:auto;max-height:75vh}
|
|
142
|
+
.cp{max-height:75vh;overflow:auto}
|
|
143
|
+
.code{font:12px/1.55 ui-monospace,monospace;background:var(--bg);overflow:auto;max-height:70vh}
|
|
144
|
+
.panel .code{border:0}
|
|
145
|
+
.cl,.diff div{white-space:pre}
|
|
146
|
+
.cl{display:flex}
|
|
147
|
+
.cl i{flex:none;width:52px;text-align:right;padding-right:12px;color:var(--mute);font-style:normal;user-select:none}
|
|
148
|
+
.cl.hit{background:rgba(245,158,11,.28)}
|
|
149
|
+
.diff .add{background:rgba(22,163,74,.16)}.diff .del{background:rgba(220,38,38,.14)}.diff .hunk{color:var(--mute)}
|
|
150
|
+
details{margin:8px 0}summary{cursor:pointer;font:12px ui-monospace,monospace}
|
|
151
|
+
details .code{border:1px solid var(--line);border-radius:6px;background:var(--card)}
|
|
152
|
+
pre.msg,pre.readme{white-space:pre-wrap;font:12px/1.5 ui-monospace,monospace;margin:6px 0}
|
|
153
|
+
pre.readme{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px;max-height:420px;overflow:auto}
|
|
154
|
+
.sr{padding:3px 0;overflow:hidden;text-overflow:ellipsis}
|
|
155
|
+
.snip{font:12px ui-monospace,monospace;color:var(--mute)}
|
|
156
|
+
mark{background:rgba(245,158,11,.35);color:inherit;border-radius:2px}
|
|
157
|
+
.big{width:100%;padding-right:34px}
|
|
158
|
+
.sbox{position:relative;flex:0 1 440px;width:100%;display:inline-flex;align-items:center}
|
|
159
|
+
.x.clear{position:absolute;right:5px;width:24px;height:24px;border-color:transparent;background:none;color:var(--mute)}
|
|
160
|
+
.x.clear:hover{color:var(--fg);border-color:var(--line)}
|
|
161
|
+
input[type=checkbox],input[type=radio]{width:auto}
|
|
162
|
+
label{display:inline-flex;gap:4px;align-items:center;color:var(--mute);font-size:12px}
|
|
163
|
+
.gr.sel{background:var(--card)}
|
|
164
|
+
.gr{cursor:pointer;min-width:0}
|
|
165
|
+
.gr .refs{flex:none;max-width:40%;overflow:hidden;display:flex}
|
|
166
|
+
.gr .s{flex:1;min-width:0}
|
|
167
|
+
.hist .gr .a{display:none}
|
|
168
|
+
.gr{display:flex;align-items:center;height:26px;white-space:nowrap}
|
|
169
|
+
.gr svg{flex:none}
|
|
170
|
+
.gr.focus{background:var(--card);outline:1px solid var(--fg)}
|
|
171
|
+
.gr .h{font:11px ui-monospace,monospace;color:var(--mute);margin:0 8px}
|
|
172
|
+
.gr .s{overflow:hidden;text-overflow:ellipsis}
|
|
173
|
+
.gr .a{color:var(--mute);font-size:12px;margin-left:10px}
|
|
174
|
+
.ref{font-size:11px;border:1px solid var(--line);border-radius:10px;padding:0 6px;margin-right:4px;color:var(--mute)}
|
|
175
|
+
.ref.local{color:var(--fg);border-color:var(--fg)}.ref.tag{color:var(--warn);border-color:var(--warn)}.ref.head{background:var(--fg);color:var(--bg)}
|
|
176
|
+
a.br{cursor:pointer;text-decoration:underline dotted}
|
|
177
|
+
</style>
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
<main>
|
|
181
|
+
<header>
|
|
182
|
+
<img class="logo" src="/icon.svg" alt="">
|
|
183
|
+
<h1>gitroster</h1>
|
|
184
|
+
<span class="sub" id="sub">loading…</span>
|
|
185
|
+
<span class="sp"></span>
|
|
186
|
+
<input id="q" placeholder="filter ( / )" autocomplete="off">
|
|
187
|
+
<a href="/api/export.md" download="gitroster.md" class="ibw"><button class="ib" title="Export everything as Markdown" aria-label="Export as Markdown"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg></button></a>
|
|
188
|
+
<button id="cfg" class="ib" title="Settings" aria-label="Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
|
189
|
+
<button id="re" class="ib" title="Rescan" aria-label="Rescan"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
|
190
|
+
</header>
|
|
191
|
+
|
|
192
|
+
<nav id="tabs">
|
|
193
|
+
<a data-tab="repos" class="on">Repos</a>
|
|
194
|
+
<a data-tab="search">Search</a>
|
|
195
|
+
<a data-tab="ports">Ports</a>
|
|
196
|
+
</nav>
|
|
197
|
+
|
|
198
|
+
<section id="repos">
|
|
199
|
+
<div class="bar" id="chips"></div>
|
|
200
|
+
<div class="bar" id="chips2"></div>
|
|
201
|
+
<div class="bar"><span class="meta">Sort</span>
|
|
202
|
+
<select id="sort"><option value="recent">last commit</option><option value="size">size</option><option value="name">name</option></select>
|
|
203
|
+
</div>
|
|
204
|
+
<div id="repolist"></div>
|
|
205
|
+
</section>
|
|
206
|
+
<section id="search" class="hide">
|
|
207
|
+
<div id="sback" class="hide"></div>
|
|
208
|
+
<div class="bar"><span class="sbox"><input id="sq" class="big" placeholder="Search all your repos" autocomplete="off"><button id="sclear" class="x clear hide" aria-label="Clear search" title="Clear (Esc)"><svg width="12" height="12" viewBox="0 0 14 14"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button></span>
|
|
209
|
+
<label><input type="checkbox" data-scope="code" checked>code</label><label><input type="checkbox" data-scope="files" checked>files</label><label><input type="checkbox" data-scope="commits" checked>commits</label><label><input type="checkbox" data-scope="packages" checked>packages</label><label><input type="checkbox" id="sre">regex</label></div>
|
|
210
|
+
<div id="sres"></div>
|
|
211
|
+
</section>
|
|
212
|
+
<section id="ports" class="hide"></section>
|
|
213
|
+
</main>
|
|
214
|
+
<div id="page"><div class="pm">
|
|
215
|
+
<header class="ph">
|
|
216
|
+
<div><b id="ptitle"></b> <span id="ptags"></span> <a id="plink" class="hide" target="_blank" rel="noopener"></a><div class="path" id="ppath"></div></div>
|
|
217
|
+
<span class="sp"></span>
|
|
218
|
+
<span id="popen"></span>
|
|
219
|
+
<button id="pclose" class="x" aria-label="Close" title="Close (Esc)"><svg width="14" height="14" viewBox="0 0 14 14"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
|
|
220
|
+
</header>
|
|
221
|
+
<nav id="ptabs"></nav><div id="pbody"></div></div></div>
|
|
222
|
+
<div id="settings"><div class="card">
|
|
223
|
+
<div class="ph" style="margin-bottom:12px"><b>Settings</b><span class="sp"></span><button id="sclose" class="x" aria-label="Close"><svg width="14" height="14" viewBox="0 0 14 14"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div>
|
|
224
|
+
<div id="sbody"></div></div></div>
|
|
225
|
+
<div id="toast"></div>
|
|
226
|
+
|
|
227
|
+
<script>
|
|
228
|
+
const $ = s => document.querySelector(s);
|
|
229
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
230
|
+
const home = p => S.home && p.startsWith(S.home) ? '~' + p.slice(S.home.length) : p;
|
|
231
|
+
const size = b => b == null ? '?' : b >= 2**30 ? (b/2**30).toFixed(1)+' GB' : b >= 2**20 ? (b/2**20).toFixed(0)+' MB' : Math.max(1, Math.round(b/1024))+' KB';
|
|
232
|
+
const days = t => t ? (Date.now()/1000 - t) / 86400 : Infinity;
|
|
233
|
+
const ago = t => { const d = days(t); return d === Infinity ? 'no commits' : d < 1 ? 'today' : d < 60 ? Math.floor(d)+'d ago' : d < 700 ? Math.floor(d/30)+'mo ago' : Math.floor(d/365)+'y ago'; };
|
|
234
|
+
const DORMANT = 90;
|
|
235
|
+
|
|
236
|
+
let S = {repos: [], envs: [], ports: [], containers: [], meta: {}};
|
|
237
|
+
let ui = {tab: 'repos', chip: 'all', more: false, open: new Set(), busy: new Set(), logs: {}};
|
|
238
|
+
try { Object.assign(ui, JSON.parse(localStorage.getItem('gr-ui') || '{}'), {open: new Set(), busy: new Set(), logs: {}}); } catch {}
|
|
239
|
+
if (!['repos', 'search', 'ports'].includes(ui.tab)) ui.tab = 'repos';
|
|
240
|
+
const saveUi = () => { try { localStorage.setItem('gr-ui', JSON.stringify({tab: ui.tab, chip: ui.chip, more: ui.more, sort: $('#sort').value})); } catch {} };
|
|
241
|
+
|
|
242
|
+
function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('on'), 2200); }
|
|
243
|
+
async function api(path, body) {
|
|
244
|
+
const r = await fetch(path, body ? {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)} : {});
|
|
245
|
+
const j = await r.json();
|
|
246
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
247
|
+
return j;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const meta = r => S.meta[r.path] || {};
|
|
251
|
+
const running = r => r.ports.length > 0;
|
|
252
|
+
const dormant = r => !running(r) && days(r.last_commit) > DORMANT;
|
|
253
|
+
const zombie = r => running(r) && days(r.last_commit) > DORMANT && !meta(r).pinned;
|
|
254
|
+
const W = r => r.work || {stashes: [], branches: [], worktrees: []};
|
|
255
|
+
const nStash = r => W(r).stashes.length;
|
|
256
|
+
const nLocal = r => W(r).branches.filter(b => b.state === 'local-only').length;
|
|
257
|
+
const nTrees = r => W(r).worktrees.length;
|
|
258
|
+
const repoByPath = () => Object.fromEntries(S.repos.map(r => [r.path, r]));
|
|
259
|
+
|
|
260
|
+
/* ---------- data ---------- */
|
|
261
|
+
async function load() {
|
|
262
|
+
try { S = await api('/api/state'); if (!S.repos) S = {repos: [], envs: [], ports: [], containers: [], meta: {}}; } catch (e) { toast(e.message); }
|
|
263
|
+
render();
|
|
264
|
+
await rescan();
|
|
265
|
+
}
|
|
266
|
+
async function rescan() {
|
|
267
|
+
$('#re').disabled = true; $('#sub').textContent = 'scanning…';
|
|
268
|
+
try { S = await api('/api/scan', {}); } catch (e) { toast(e.message); }
|
|
269
|
+
$('#re').disabled = false; render();
|
|
270
|
+
}
|
|
271
|
+
async function pollPorts() {
|
|
272
|
+
if (document.hidden || $('#re').disabled) return;
|
|
273
|
+
const a = document.activeElement;
|
|
274
|
+
if (a && (a.tagName === 'TEXTAREA')) return;
|
|
275
|
+
try { const p = await api('/api/ports'); S.ports = p.ports; S.containers = p.containers;
|
|
276
|
+
for (const r of S.repos) r.ports = [...new Set(S.ports.filter(x => x.repo === r.path).map(x => x.port))].sort((a, b) => a - b);
|
|
277
|
+
render(); } catch {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------- render ---------- */
|
|
281
|
+
function render() {
|
|
282
|
+
const q = $('#q').value.toLowerCase();
|
|
283
|
+
const total = S.repos.reduce((a, r) => a + (r.size || 0), 0);
|
|
284
|
+
$('#sub').textContent = S.scanned ? `${S.repos.length} repos · ${S.ports.length} ports · ${size(total)}${S.demo ? ' · demo data' : ''}` : 'no scan yet';
|
|
285
|
+
document.querySelectorAll('#tabs a').forEach(a => a.classList.toggle('on', a.dataset.tab === ui.tab));
|
|
286
|
+
for (const id of ['repos', 'search', 'ports']) $('#' + id).classList.toggle('hide', id !== ui.tab);
|
|
287
|
+
$('#q').style.visibility = ui.tab === 'search' ? 'hidden' : '';
|
|
288
|
+
if (ui.tab === 'repos') renderRepos(q);
|
|
289
|
+
if (ui.tab === 'ports') renderPorts(q);
|
|
290
|
+
if (ui.tab === 'search' && !$('#sres').innerHTML) runSearch();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const PRIMARY_CHIPS = ['all', 'running', 'uncommitted', 'unpushed'];
|
|
294
|
+
function chips() {
|
|
295
|
+
const R = S.repos.filter(r => !meta(r).ignored);
|
|
296
|
+
const n = {all: R.length, running: R.filter(running).length, uncommitted: R.filter(r => r.dirty).length,
|
|
297
|
+
unpushed: R.filter(r => r.ahead).length, mine: R.filter(r => r.kind === 'mine').length, fork: R.filter(r => r.kind === 'fork').length,
|
|
298
|
+
clone: R.filter(r => r.kind === 'clone').length, local: R.filter(r => r.kind === 'local').length, dormant: R.filter(dormant).length,
|
|
299
|
+
issues: R.filter(r => (r.health || []).length).length, stashed: R.filter(nStash).length, branches: R.filter(nLocal).length,
|
|
300
|
+
worktrees: R.filter(nTrees).length, copies: R.filter(r => (r.same_remote || []).length).length, pinned: R.filter(r => meta(r).pinned).length,
|
|
301
|
+
ignored: S.repos.filter(r => meta(r).ignored).length};
|
|
302
|
+
const btn = k => `<button class="chip ${ui.chip === k ? 'on' : ''}" data-chip="${k}">${k} ${n[k]}</button>`;
|
|
303
|
+
const rest = Object.keys(n).filter(k => !PRIMARY_CHIPS.includes(k) && (n[k] > 0 || ui.chip === k)); // empty filters are noise
|
|
304
|
+
$('#chips').innerHTML = PRIMARY_CHIPS.map(btn).join('') + (rest.length ? `<button class="chip more" data-more>${ui.more ? 'Fewer' : 'More'}</button>` : '');
|
|
305
|
+
$('#chips2').innerHTML = ui.more ? rest.map(btn).join('') : (rest.includes(ui.chip) ? btn(ui.chip) : '');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderRepos(q) {
|
|
309
|
+
chips();
|
|
310
|
+
const sort = $('#sort').value;
|
|
311
|
+
const f = {all: r => true, running, uncommitted: r => r.dirty, unpushed: r => r.ahead > 0, mine: r => r.kind === 'mine', fork: r => r.kind === 'fork', clone: r => r.kind === 'clone', local: r => r.kind === 'local', dormant, issues: r => (r.health || []).length, stashed: nStash, branches: nLocal, worktrees: nTrees, copies: r => (r.same_remote || []).length, pinned: r => meta(r).pinned, ignored: r => true}[ui.chip];
|
|
312
|
+
let list = S.repos.filter(r => (ui.chip === 'ignored') === !!meta(r).ignored && f(r) &&
|
|
313
|
+
(!q || `${r.name} ${r.path} ${r.stack.join(' ')} ${r.summary} ${meta(r).note || ''}`.toLowerCase().includes(q)));
|
|
314
|
+
list.sort((a, b) => (meta(b).pinned ? 1 : 0) - (meta(a).pinned ? 1 : 0) || (sort === 'size' ? (b.size || 0) - (a.size || 0) : sort === 'name' ? a.name.localeCompare(b.name) : (b.last_commit || 0) - (a.last_commit || 0)));
|
|
315
|
+
$('#repolist').innerHTML = list.length ? list.map(repoRow).join('') : '<div class="empty">Nothing here.</div>';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ---------- AI summary ---------- */
|
|
319
|
+
const ICON_AI = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9zM19 16v4M17 18h4"/></svg>';
|
|
320
|
+
const aiOf = r => meta(r).summary;
|
|
321
|
+
const aiBusy = r => ui.busy.has('ai' + r.path);
|
|
322
|
+
const aiStale = r => { const s = aiOf(r); return !!(s && s.head && r.head && s.head !== r.head); };
|
|
323
|
+
function summaryBlock(r) {
|
|
324
|
+
const s = aiOf(r), busy = aiBusy(r);
|
|
325
|
+
const btn = `<button class="go" data-ai="" ${busy ? 'disabled' : ''}>${busy ? 'Summarizing…' : s ? 'Refresh' : 'Summarize with AI'}</button>`;
|
|
326
|
+
if (!s) return `<div class="line"><span class="k">AI</span>${btn}<span class="meta">runs the agent CLI you chose in Settings · nothing is executed</span></div>`;
|
|
327
|
+
const rows = s.raw ? `<div>${esc(s.raw)}</div>` : [['what', 'What'], ['stack', 'Stack'], ['run', 'Run'], ['status', 'Status'], ['watch', 'Watch out']]
|
|
328
|
+
.filter(([k]) => s[k] && s[k].toLowerCase() !== 'none').map(([k, l]) => `<div class="line"><span class="k">${l}</span><span class="v">${esc(s[k])}</span></div>`).join('');
|
|
329
|
+
return `<div class="ai"><div class="aihead">${ICON_AI}<b>Written by AI</b><span class="meta">${esc(s.agent || 'agent')} · ${ago(s.at)}</span><span class="tag">unverified</span>${aiStale(r) ? '<span class="tag warn">repo changed since</span>' : ''}</div>${rows}<div class="meta aifoot">Based on this repo's README, file list, dependency names and recent commit titles. ${btn}</div></div>`;
|
|
330
|
+
}
|
|
331
|
+
async function runAi(path) {
|
|
332
|
+
if (ui.busy.has('ai' + path)) return;
|
|
333
|
+
const refresh = () => { render(); if ($('#page').classList.contains('on') && pg.path === path && pg.tab === 'overview') renderPage(); };
|
|
334
|
+
ui.busy.add('ai' + path); toast('Summarizing… this can take up to a minute'); refresh();
|
|
335
|
+
try { S.meta[path] = await api('/api/summarize', {path}); toast('Summary ready'); }
|
|
336
|
+
catch (e) { toast(e.message); if (/no agent CLI/.test(e.message)) openSettings(); }
|
|
337
|
+
finally { ui.busy.delete('ai' + path); refresh(); }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function repoRow(r) {
|
|
341
|
+
const m = meta(r), open = ui.open.has(r.path), p = r.path;
|
|
342
|
+
return `<div class="row ${open ? 'open' : ''}" data-path="${esc(p)}">
|
|
343
|
+
<div class="top" data-toggle>
|
|
344
|
+
<button class="pin ${m.pinned ? 'on' : ''}" data-pin title="Pin">${m.pinned ? '★' : '☆'}</button>
|
|
345
|
+
<span class="name">${esc(r.name)}</span>
|
|
346
|
+
${r.ports.length ? `<span class="tag port">:${r.ports.slice(0, 3).join(' :')}${r.ports.length > 3 ? ` +${r.ports.length - 3}` : ''}</span>` : ''}
|
|
347
|
+
${r.dirty ? '<span class="tag warn">uncommitted</span>' : ''}
|
|
348
|
+
${r.ahead ? `<span class="tag warn">↑${r.ahead} unpushed</span>` : ''}
|
|
349
|
+
${dormant(r) ? '<span class="tag">dormant</span>' : ''}
|
|
350
|
+
${zombie(r) ? '<span class="tag warn">zombie</span>' : ''}
|
|
351
|
+
${nStash(r) ? `<span class="tag warn">${nStash(r)} stash${nStash(r) > 1 ? 'es' : ''}</span>` : ''}
|
|
352
|
+
${nLocal(r) ? `<span class="tag warn">${nLocal(r)} local-only branch${nLocal(r) > 1 ? 'es' : ''}</span>` : ''}
|
|
353
|
+
${(r.same_remote || []).length ? `<span class="tag warn">cloned ${r.same_remote.length + 1}×</span>` : ''}
|
|
354
|
+
${nTrees(r) ? `<span class="tag">${nTrees(r)} worktree${nTrees(r) > 1 ? 's' : ''}</span>` : ''}
|
|
355
|
+
${(r.health || []).length ? `<span class="tag warn">${r.health.length} issue${r.health.length > 1 ? 's' : ''}</span>` : ''}
|
|
356
|
+
<span class="tag">${esc(r.kind)}</span>
|
|
357
|
+
${r.stack.map(s => `<span class="tag">${esc(s)}</span>`).join('')}
|
|
358
|
+
<span class="sp"></span>
|
|
359
|
+
<span class="meta">${size(r.size)} · ${ago(r.last_commit)}</span>
|
|
360
|
+
<button class="go primary" data-pp="${esc(p)}" data-pt="overview">Details</button>
|
|
361
|
+
<button class="ib xs ai-btn ${aiBusy(r) ? 'busy' : aiOf(r) ? (aiStale(r) ? 'stale' : 'on') : ''}" data-ai="icon" title="${aiOf(r) ? 'AI summary' + (aiStale(r) ? ' (repo changed since)' : '') : 'Summarize with AI'}" aria-label="AI summary">${ICON_AI}</button>
|
|
362
|
+
</div>
|
|
363
|
+
<div class="path">${esc(home(p))}${r.branch ? ` · ${esc(r.branch)}` : ''}</div>
|
|
364
|
+
${open ? detail(r) : ''}
|
|
365
|
+
</div>`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function detail(r) {
|
|
369
|
+
const m = meta(r), busy = ui.busy;
|
|
370
|
+
const cmd = c => `<code>${esc(c)}</code>`;
|
|
371
|
+
const ports = S.ports.filter(x => x.repo === r.path);
|
|
372
|
+
let h = '<div class="detail">';
|
|
373
|
+
if (r.summary) h += `<div class="line">${esc(r.summary)}</div><div class="meta" style="margin:-2px 0 6px">from ${esc(r.readme_file || 'README')} · not AI</div>`;
|
|
374
|
+
h += `<div class="line"><span class="k">note</span></div><textarea data-note placeholder="What is this? How do you use it?">${esc(m.note || '')}</textarea>`;
|
|
375
|
+
if (r.remote) h += `<div class="line"><span class="k">remote</span><span class="mono">${esc(r.remote)}</span></div>`;
|
|
376
|
+
if (r.envs.length) h += `<div class="line"><span class="k">env</span><span>${r.envs.map(e => `${cmd(home(e.path))} <span class="meta">${e.kind}${e.python ? ' ' + esc(e.python) : ''} · ${size(e.size)}</span>`).join('<br>')}</span></div>`;
|
|
377
|
+
if (r.activate) h += `<div class="line"><span class="k">activate</span>${cmd('cd ' + home(r.path) + ' && ' + r.activate)}</div>`;
|
|
378
|
+
if (r.setup.length) h += `<div class="line"><span class="k">setup</span>${r.setup.map(cmd).join(' ')}</div>`;
|
|
379
|
+
if (r.run.length) h += `<div class="line"><span class="k">run</span>${r.run.map(c => `<span>${cmd(c)} <button class="go" data-start="${esc(c)}" ${busy.has(r.path) ? 'disabled' : ''}>▶ Start</button></span>`).join('')}</div>`;
|
|
380
|
+
else h += '<div class="line meta">No run command detected.</div>';
|
|
381
|
+
if (r.cmds.length) h += `<div class="line"><span class="k">README</span>${r.cmds.map(cmd).join(' ')}</div>`;
|
|
382
|
+
if (r.expected_ports.length) h += `<div class="line"><span class="k">expects</span><span class="meta">${r.expected_ports.map(p => ':' + p).join(' ')} (from README)</span></div>`;
|
|
383
|
+
if (ports.length) h += `<div class="line"><span class="k">listening</span>${ports.map(p => `<a href="http://localhost:${p.port}" target="_blank" rel="noopener">:${p.port}</a> <span class="meta">${esc(p.proc)}</span>`).join(' · ')}</div>`;
|
|
384
|
+
const w = W(r), stateTag = b => b.state === 'local-only' ? `<span class="tag warn">${b.gone ? 'upstream gone' : b.ahead ? '↑' + b.ahead + ' unpushed' : 'local only'}</span>` : '<span class="tag">pushed, not merged</span>';
|
|
385
|
+
if (w.stashes.length) h += `<div class="line"><span class="k">stash</span><span>${w.stashes.map(x => `${cmd('git stash show -p ' + x.ref)} <span class="meta">${esc(x.message)} · ${ago(x.time)}</span>`).join('<br>')}</span></div>`;
|
|
386
|
+
if (w.branches.length) h += `<div class="line"><span class="k">branches</span><span>${w.branches.slice(0, 8).map(b => `<a class="br" data-pp="${esc(r.path)}" data-pt="history" data-pfocus="${esc(b.name)}">${esc(b.name)}</a> ${stateTag(b)} <span class="meta">${ago(b.time)}</span>`).join('<br>')}${w.branches.length > 8 ? `<br><span class="meta">+${w.branches.length - 8} more, open the history</span>` : ''}${w.merged_branches ? `<br><span class="meta">${w.merged_branches} more already merged into ${esc(w.default)}</span>` : ''}</span></div>`;
|
|
387
|
+
if (w.worktrees.length) h += `<div class="line"><span class="k">worktrees</span><span>${w.worktrees.map(t => `${cmd(home(t.path))} <span class="meta">${esc(t.branch || 'detached')}</span>${t.missing ? ' <span class="tag warn">folder missing · git worktree prune</span>' : ''}`).join('<br>')}</span></div>`;
|
|
388
|
+
h += `<div class="line"><span class="k">open</span><button class="go primary" data-pp="${esc(r.path)}" data-pt="overview">Details</button><button class="go" data-pp="${esc(r.path)}" data-pt="history">History</button>${openBtns()}</div>`;
|
|
389
|
+
for (const i of r.health || []) h += `<div class="line"><span class="k">health</span><span class="tag warn">${esc(i.text)}</span>${i.files.map(f => `<span class="mono">${esc(f)}</span>`).join(' ')}</div>`;
|
|
390
|
+
if (zombie(r)) h += `<div class="line"><span class="k">zombie</span><span class="meta">holds ${r.ports.map(p => ':' + p).join(' ')} but has no commit for ${Math.floor(days(r.last_commit))} days. Stop it from the Ports tab if you forgot about it.</span></div>`;
|
|
391
|
+
if ((r.subprojects || []).length) h += `<div class="line"><span class="k">inside</span><span>${r.subprojects.map(sp => `<b>${esc(sp.name)}</b> <span class="meta">${esc(sp.stack.join(' '))}</span> ${sp.run.map(c => cmd('cd ' + sp.name + ' && ' + c)).join(' ')}`).join('<br>')}</span></div>`;
|
|
392
|
+
h += summaryBlock(r);
|
|
393
|
+
if (ui.logs[r.path]) h += `<div class="log">${esc(ui.logs[r.path])}</div>`;
|
|
394
|
+
h += `<div class="line"><span class="k"></span><button class="go" data-ignore>${m.ignored ? 'Unignore' : 'Ignore'}</button></div>`;
|
|
395
|
+
return h + '</div>';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderPorts(q) {
|
|
399
|
+
const rp = repoByPath();
|
|
400
|
+
const ports = S.ports.filter(p => !q || `${p.port} ${p.proc} ${p.cmd} ${p.repo || ''}`.toLowerCase().includes(q));
|
|
401
|
+
const owned = ports.filter(p => p.repo).length;
|
|
402
|
+
let h = `<div class="stats"><div><b>${ports.length}</b><span>listening</span></div><div><b>${owned}</b><span>matched to a repo</span></div><div><b>${ports.filter(p => p.exposed).length}</b><span>on all interfaces</span></div></div>`;
|
|
403
|
+
h += '<table>' + (ports.length ? ports.map(p => `<tr>
|
|
404
|
+
<td class="mono"><b><a href="http://localhost:${p.port}" target="_blank" rel="noopener">${p.port}</a></b></td>
|
|
405
|
+
<td>${p.repo ? esc(rp[p.repo].name) : '<span class="meta">unknown</span>'}
|
|
406
|
+
${p.exposed ? '<span class="tag warn">all interfaces</span>' : ''}
|
|
407
|
+
${p.repo && zombie(rp[p.repo]) ? '<span class="tag warn">zombie</span>' : ''}
|
|
408
|
+
<div class="path">${esc(p.proc)} · pid ${p.pid} · up ${ago(p.since)}</div>
|
|
409
|
+
<div class="path" title="${esc(p.cmd)}">${esc(p.cmd.slice(0, 110))}</div></td>
|
|
410
|
+
<td class="r"><button data-kill="${p.pid}" data-port="${p.port}">Stop</button></td></tr>`).join('') : '<tr><td class="empty">Nothing listening.</td></tr>') + '</table>';
|
|
411
|
+
if (S.containers.length) {
|
|
412
|
+
h += '<h2>Docker</h2><table>' + S.containers.map(c => `<tr><td><b>${esc(c.name)}</b> <span class="meta">${esc(c.image)}</span>
|
|
413
|
+
<div class="path">${esc(c.ports)} · ${esc(c.status)}</div></td><td class="r">${c.repo ? esc(rp[c.repo].name) : ''}</td></tr>`).join('') + '</table>';
|
|
414
|
+
}
|
|
415
|
+
$('#ports').innerHTML = h;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* ---------- commit graph ---------- */
|
|
419
|
+
const LANE = ['#6b7280', '#2563eb', '#16a34a', '#d97706', '#9333ea', '#dc2626', '#0891b2', '#be185d'];
|
|
420
|
+
function layoutGraph(commits) {
|
|
421
|
+
let lanes = [], rows = [], maxw = 1;
|
|
422
|
+
for (const c of commits) {
|
|
423
|
+
const cols = []; lanes.forEach((h, i) => { if (h === c.hash) cols.push(i); });
|
|
424
|
+
let col = cols.length ? cols[0] : lanes.indexOf(null);
|
|
425
|
+
if (col < 0) col = lanes.length;
|
|
426
|
+
const before = lanes.slice();
|
|
427
|
+
for (const i of cols) lanes[i] = null;
|
|
428
|
+
lanes[col] = null;
|
|
429
|
+
const edges = [];
|
|
430
|
+
c.parents.forEach((p, k) => {
|
|
431
|
+
let j = lanes.indexOf(p);
|
|
432
|
+
if (j < 0) { j = k === 0 ? col : (lanes.indexOf(null) < 0 ? lanes.length : lanes.indexOf(null)); lanes[j] = p; }
|
|
433
|
+
edges.push(j);
|
|
434
|
+
});
|
|
435
|
+
const pass = []; before.forEach((h, i) => { if (h && !cols.includes(i)) pass.push(i); });
|
|
436
|
+
rows.push({c, col, cols, edges, pass});
|
|
437
|
+
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
|
|
438
|
+
maxw = Math.max(maxw, lanes.length, before.length, col + 1, ...edges.map(e => e + 1));
|
|
439
|
+
}
|
|
440
|
+
return {rows, maxw};
|
|
441
|
+
}
|
|
442
|
+
const refChip = x => x.startsWith('HEAD -> ') ? `<span class="ref head">${esc(x.slice(8))}</span>` : x.startsWith('tag: ') ? `<span class="ref tag">${esc(x.slice(5))}</span>` : x.includes('/') && x.split('/')[0] === 'origin' || x.startsWith('upstream/') ? `<span class="ref">${esc(x)}</span>` : x === 'HEAD' ? '' : `<span class="ref local">${esc(x)}</span>`;
|
|
443
|
+
function drawRow({c, col, cols, edges, pass}, maxw, focus) {
|
|
444
|
+
const X = i => 10 + i * 14, H = 26, M = 13, color = i => LANE[i % LANE.length];
|
|
445
|
+
let g = '';
|
|
446
|
+
for (const i of pass) g += `<line x1="${X(i)}" y1="0" x2="${X(i)}" y2="${H}" stroke="${color(i)}" stroke-width="2"/>`;
|
|
447
|
+
for (const i of cols) g += `<line x1="${X(i)}" y1="0" x2="${X(col)}" y2="${M}" stroke="${color(i)}" stroke-width="2"/>`;
|
|
448
|
+
for (const j of edges) g += `<line x1="${X(col)}" y1="${M}" x2="${X(j)}" y2="${H}" stroke="${color(j)}" stroke-width="2"/>`;
|
|
449
|
+
g += `<circle cx="${X(col)}" cy="${M}" r="4.5" fill="${c.parents.length > 1 ? 'var(--bg)' : color(col)}" stroke="${color(col)}" stroke-width="2"/>`;
|
|
450
|
+
const hit = focus && c.refs.some(x => x === focus || x === 'HEAD -> ' + focus);
|
|
451
|
+
return `<div class="gr ${hit ? 'focus' : ''}" data-hash="${c.hash}" ${hit ? 'data-focus' : ''}><svg width="${maxw * 14 + 14}" height="${H}">${g}</svg>
|
|
452
|
+
<span class="h">${esc(c.hash.slice(0, 7))}</span><span class="refs">${c.refs.map(refChip).join('')}</span><span class="s" title="${esc(c.subject)}">${esc(c.subject)}</span><span class="a">${esc(c.author)} · ${ago(c.time)}</span></div>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* ---------- dependencies across repos ---------- */
|
|
456
|
+
let depCache = {at: null, map: null};
|
|
457
|
+
function depIndex() {
|
|
458
|
+
if (depCache.at === S.scanned && depCache.map) return depCache.map;
|
|
459
|
+
const map = new Map();
|
|
460
|
+
for (const r of S.repos) {
|
|
461
|
+
if (meta(r).ignored) continue;
|
|
462
|
+
for (const d of r.deps || []) {
|
|
463
|
+
const key = d.eco + ':' + d.name;
|
|
464
|
+
if (!map.has(key)) map.set(key, {key, eco: d.eco, name: d.name, items: []});
|
|
465
|
+
map.get(key).items.push({repo: r, spec: d.spec || '', dev: d.dev, where: d.where || ''});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
depCache = {at: S.scanned, map};
|
|
469
|
+
return map;
|
|
470
|
+
}
|
|
471
|
+
const specs = p => [...new Set(p.items.map(i => i.spec || 'any'))];
|
|
472
|
+
const drifts = p => repoCount(p) > 1 && new Set(p.items.map(i => i.spec).filter(Boolean)).size > 1;
|
|
473
|
+
const repoCount = p => new Set(p.items.map(i => i.repo.path)).size;
|
|
474
|
+
const link = (r, tab = 'overview', extra = '') => `<a class="br" data-pp="${esc(r.path)}" data-pt="${tab}" ${extra}>${esc(r.name)}</a>`;
|
|
475
|
+
|
|
476
|
+
/* ---------- search ---------- */
|
|
477
|
+
let searchSeq = 0, searchTimer = null;
|
|
478
|
+
const hl = (text, q, regex) => {
|
|
479
|
+
if (regex || !q) return esc(text);
|
|
480
|
+
const low = text.toLowerCase(), ql = q.toLowerCase();
|
|
481
|
+
let out = '', i = 0, j;
|
|
482
|
+
while ((j = low.indexOf(ql, i)) >= 0) { out += esc(text.slice(i, j)) + '<mark>' + esc(text.slice(j, j + q.length)) + '</mark>'; i = j + q.length; }
|
|
483
|
+
return out + esc(text.slice(i));
|
|
484
|
+
};
|
|
485
|
+
const recent = () => { try { return JSON.parse(localStorage.getItem('gr-recent') || '[]'); } catch { return []; } };
|
|
486
|
+
const remember = q => { try { localStorage.setItem('gr-recent', JSON.stringify([q, ...recent().filter(x => x !== q)].slice(0, 8))); } catch {} };
|
|
487
|
+
function showBack() {
|
|
488
|
+
const b = $('#sback'), r = ui.back && repoOf(ui.back.path);
|
|
489
|
+
b.classList.toggle('hide', !r);
|
|
490
|
+
if (r) b.innerHTML = `<button class="go" data-back>← Back to ${esc(r.name)} · ${esc((PTABS.find(t => t[0] === ui.back.tab) || ['', 'Overview'])[1])}</button>`;
|
|
491
|
+
}
|
|
492
|
+
function searchFor(q, scopes, regex = false) {
|
|
493
|
+
$('#sq').value = q; $('#sre').checked = regex; $('#sclear').classList.toggle('hide', !q);
|
|
494
|
+
document.querySelectorAll('[data-scope]').forEach(x => { x.checked = !scopes || scopes.includes(x.dataset.scope); });
|
|
495
|
+
runSearch();
|
|
496
|
+
}
|
|
497
|
+
function searchHome() {
|
|
498
|
+
const pk = [...depIndex().values()].sort((a, b) => repoCount(b) - repoCount(a)).slice(0, 4).map(p => p.name);
|
|
499
|
+
const ex = q => `<button class="chip" data-sq="${esc(q)}">${esc(q)}</button>`, rc = recent();
|
|
500
|
+
return `<div class="empty-hero">
|
|
501
|
+
<p class="lead">Search everything you have written, across all ${S.repos.filter(r => !meta(r).ignored).length} repos at once.</p>
|
|
502
|
+
<div class="meta">It looks in tracked files, file names, commit messages and package names.</div>
|
|
503
|
+
<h2>Try</h2><div class="bar">${['TODO', 'FIXME', 'localhost:', 'password', 'api_key'].map(ex).join('')}<button class="chip" data-sq="def \\w+\\(" data-regex="1">regex: def \\w+\\(</button></div>
|
|
504
|
+
${pk.length ? `<h2>Packages you use</h2><div class="bar">${pk.map(ex).join('')}</div>` : ''}
|
|
505
|
+
${rc.length ? `<h2>Recent</h2><div class="bar">${rc.map(ex).join('')}</div>` : ''}
|
|
506
|
+
<h2>Good to know</h2>
|
|
507
|
+
<div class="meta">Matching ignores case. Click a result to open that file at the right line, or that commit with its diff. Turn on <b>regex</b> for patterns.</div></div>`;
|
|
508
|
+
}
|
|
509
|
+
async function runSearch() {
|
|
510
|
+
const q = $('#sq').value.trim(), regex = $('#sre').checked;
|
|
511
|
+
const on = [...document.querySelectorAll('[data-scope]')].filter(x => x.checked).map(x => x.dataset.scope);
|
|
512
|
+
const scope = on.filter(x => x !== 'packages').join(','), box = $('#sres'), seq = ++searchSeq;
|
|
513
|
+
if (q.length < 2) { box.innerHTML = searchHome(); return; }
|
|
514
|
+
box.innerHTML = '<div class="meta">Searching…</div>';
|
|
515
|
+
let re = null; try { re = regex ? new RegExp(q, 'i') : null; } catch { box.innerHTML = '<div class="empty">That regex is not valid.</div>'; return; }
|
|
516
|
+
const pkgs = on.includes('packages') ? [...depIndex().values()].filter(p => re ? re.test(p.name) : p.name.toLowerCase().includes(q.toLowerCase()))
|
|
517
|
+
.sort((a, b) => repoCount(b) - repoCount(a)).slice(0, 12) : [];
|
|
518
|
+
try {
|
|
519
|
+
const {results} = scope ? await api('/api/search?' + new URLSearchParams({q, scope, regex: regex ? '1' : '0'})) : {results: []};
|
|
520
|
+
if (seq !== searchSeq) return;
|
|
521
|
+
remember(q);
|
|
522
|
+
const hits = results.reduce((a, r) => a + r.code.length + r.files.length + r.commits.length, 0) + pkgs.length;
|
|
523
|
+
box.innerHTML = `<div class="meta" style="margin-bottom:8px">${hits} matches in ${results.length} repos${pkgs.length ? ` and ${pkgs.length} packages` : ''}</div>` +
|
|
524
|
+
(pkgs.length ? `<div class="row"><div class="top"><span class="name">Packages</span><span class="meta">used by your repos</span></div>${pkgs.map(p => `<div class="sr"><b>${hl(p.name, q, regex)}</b> <span class="tag">${esc(p.eco)}</span>${drifts(p) ? ' <span class="tag warn">versions differ</span>' : ''}
|
|
525
|
+
<div class="meta">${p.items.map(i => `${link(i.repo, 'deps')} <code>${esc(i.spec || 'any')}</code>${i.dev ? ' dev' : ''}`).join(' ')}</div></div>`).join('')}</div>` : '') +
|
|
526
|
+
results.map(r => `<div class="row">
|
|
527
|
+
<div class="top"><span class="name">${link({path: r.path, name: r.name})}</span><span class="path">${esc(home(r.path))}</span></div>
|
|
528
|
+
${r.files.map(f => `<div class="sr"><span class="tag">file</span> <a class="br" data-pp="${esc(r.path)}" data-pt="files" data-pf="${esc(f)}">${hl(f, q, regex)}</a></div>`).join('')}
|
|
529
|
+
${r.code.map(c => `<div class="sr"><a class="br mono" data-pp="${esc(r.path)}" data-pt="files" data-pf="${esc(c.file)}" data-pl="${c.line}">${esc(c.file)}:${c.line}</a> <span class="snip">${hl(c.text, q, regex)}</span></div>`).join('')}
|
|
530
|
+
${r.capped ? '<div class="meta">more matches in this repo, refine the search</div>' : ''}
|
|
531
|
+
${r.commits.map(c => `<div class="sr"><span class="tag">commit</span> <a class="br" data-pp="${esc(r.path)}" data-pt="history" data-pc="${c.hash}">${hl(c.subject, q, regex)}</a> <span class="meta">${c.hash.slice(0, 7)} · ${ago(c.time)}</span></div>`).join('')}
|
|
532
|
+
</div>`).join('') || (pkgs.length ? '' : '<div class="empty">No matches.</div>');
|
|
533
|
+
} catch (e) { if (seq === searchSeq) box.innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* ---------- repo page ---------- */
|
|
537
|
+
const pg = {path: null, tab: 'overview', ref: 'HEAD', dir: '', file: null, line: null, focus: '', commit: null, cache: {}};
|
|
538
|
+
const PTABS = [['overview', 'Overview'], ['files', 'Files'], ['history', 'History'], ['branches', 'Branches'], ['deps', 'Dependencies']];
|
|
539
|
+
const repoOf = p => S.repos.find(r => r.path === p);
|
|
540
|
+
const jget = (path, params) => api(path + '?' + new URLSearchParams(params));
|
|
541
|
+
const LANG_COLOR = ['#2563eb', '#d97706', '#16a34a', '#9333ea', '#dc2626', '#0891b2', '#be185d', '#6b7280'];
|
|
542
|
+
|
|
543
|
+
function openPage(path, tab = 'overview', o = {}) {
|
|
544
|
+
ui.back = null; showBack();
|
|
545
|
+
if (pg.path !== path) Object.assign(pg, {ref: 'HEAD', dir: '', file: null, line: null, cache: {}});
|
|
546
|
+
Object.assign(pg, {path, tab, focus: o.focus || '', commit: o.commit || null});
|
|
547
|
+
if (o.file) { pg.file = o.file; pg.line = o.line || null; pg.dir = o.file.split('/').slice(0, -1).join('/'); }
|
|
548
|
+
$('#page').classList.add('on');
|
|
549
|
+
renderPage();
|
|
550
|
+
}
|
|
551
|
+
const closePage = () => $('#page').classList.remove('on');
|
|
552
|
+
const ovOf = path => pg.cache.ovp ||= jget('/api/overview', {path});
|
|
553
|
+
const ico = d => `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
|
|
554
|
+
const OPEN_ICONS = {
|
|
555
|
+
editor: ['Open in editor', ico('<path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/>')],
|
|
556
|
+
terminal: ['Open in terminal', ico('<path d="M4 17l6-6-6-6M12 19h8"/>')],
|
|
557
|
+
folder: ['Open folder', ico('<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>')],
|
|
558
|
+
};
|
|
559
|
+
const openBtns = () => Object.entries(OPEN_ICONS).map(([k, [t, i]]) => `<button class="ib" data-open="${k}" title="${t}" aria-label="${t}">${i}</button>`).join('');
|
|
560
|
+
const ICON_GH = '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
|
|
561
|
+
const ICON_LINK = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2.5h4.5V7M13.5 2.5L7 9M11.5 9.5v3a1 1 0 01-1 1h-7a1 1 0 01-1-1v-7a1 1 0 011-1h3"/></svg>';
|
|
562
|
+
const HOSTS = {'github.com': 'GitHub', 'gitlab.com': 'GitLab', 'bitbucket.org': 'Bitbucket'};
|
|
563
|
+
|
|
564
|
+
async function renderPage() {
|
|
565
|
+
const r = repoOf(pg.path); if (!r) return closePage();
|
|
566
|
+
$('#page').scrollTop = 0;
|
|
567
|
+
$('#ptitle').textContent = r.name;
|
|
568
|
+
$('#ptags').innerHTML = `<span class="tag">${esc(r.kind)}</span>${r.stack.map(s => `<span class="tag">${esc(s)}</span>`).join('')}`;
|
|
569
|
+
$('#ppath').textContent = home(r.path) + (r.branch ? ' · ' + r.branch : '');
|
|
570
|
+
$('#ptabs').innerHTML = PTABS.map(([k, l]) => `<a data-ptab="${k}" class="${pg.tab === k ? 'on' : ''}">${l}</a>`).join('');
|
|
571
|
+
const tab = pg.tab, path = r.path, body = $('#pbody');
|
|
572
|
+
ovOf(path).then(ov => { const a = $('#plink'); if (pg.path === path && ov.web_url) { const host = ov.web_url.split('/')[2]; a.href = ov.web_url; a.innerHTML = host === 'github.com' ? ICON_GH : ICON_LINK; a.title = 'Open on ' + (HOSTS[host] || 'the remote'); a.setAttribute('aria-label', a.title); a.classList.remove('hide'); } }).catch(() => {});
|
|
573
|
+
$('#plink').classList.add('hide');
|
|
574
|
+
body.innerHTML = '<div class="empty">Loading…</div>';
|
|
575
|
+
try {
|
|
576
|
+
const html = await ({overview: pOverview, files: pFiles, history: pHistory, branches: pBranches, deps: pDeps})[tab](r);
|
|
577
|
+
if (pg.tab !== tab || pg.path !== path) return;
|
|
578
|
+
body.innerHTML = html; afterPage(tab);
|
|
579
|
+
} catch (e) { if (pg.tab === tab && pg.path === path) body.innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function pOverview(r) {
|
|
583
|
+
const ov = await ovOf(r.path);
|
|
584
|
+
const tot = ov.languages.reduce((a, l) => a + l.bytes, 0) || 1;
|
|
585
|
+
const go = (f, n) => `<a class="br mono" data-pp="${esc(r.path)}" data-pt="files" data-pf="${esc(f)}">${esc(f)}</a> <span class="meta">${n}×</span>`;
|
|
586
|
+
const rel = [];
|
|
587
|
+
if (r.same_remote?.length) rel.push(`<div class="line"><span class="k">copies</span><span>Same remote is also cloned at: ${r.same_remote.map(p => link(repoOf(p) || {path: p, name: home(p)})).join(', ')}</span></div>`);
|
|
588
|
+
if (r.remotes?.upstream) rel.push(`<div class="line"><span class="k">upstream</span><span class="mono">${esc(r.remotes.upstream)}</span></div>`);
|
|
589
|
+
if (r.uses?.length) rel.push(`<div class="line"><span class="k">uses</span><span>Depends on your other repos: ${r.uses.map(p => link(repoOf(p))).join(', ')}</span></div>`);
|
|
590
|
+
if (r.used_by?.length) rel.push(`<div class="line"><span class="k">used by</span><span>${r.used_by.map(p => link(repoOf(p))).join(', ')}</span></div>`);
|
|
591
|
+
const person = c => c.url ? `<a href="${esc(c.url)}" target="_blank" rel="noopener" class="br">${esc(c.name)}</a>` : esc(c.name);
|
|
592
|
+
return `<div class="ov"><div>
|
|
593
|
+
<div class="stats" style="margin-top:0">
|
|
594
|
+
<div><b>${ov.commits_total}</b><span>commits</span></div><div><b>${ov.files_total}</b><span>files</span></div>
|
|
595
|
+
<div><b>${ov.first_commit ? ago(ov.first_commit).replace(' ago', '') : '?'}</b><span>age</span></div>
|
|
596
|
+
<div><b>${ago(r.last_commit).replace(' ago', '')}</b><span>last commit</span></div><div><b>${size(r.size)}</b><span>on disk</span></div></div>
|
|
597
|
+
<div style="margin-bottom:14px">${summaryBlock(r)}</div>
|
|
598
|
+
${r.summary ? `<p style="margin-bottom:2px">${esc(r.summary)}</p><div class="meta" style="margin-bottom:12px">first paragraph of ${esc(r.readme_file || 'README')} · not AI</div>` : ''}
|
|
599
|
+
${meta(r).note ? `<div class="ai" style="margin-bottom:10px"><b>Your note</b><br>${esc(meta(r).note)}</div>` : ''}
|
|
600
|
+
${rel.length ? `<h2>Related</h2><div class="detail" style="display:block;margin-top:0">${rel.join('')}</div>` : ''}
|
|
601
|
+
${r.run.length ? `<h2>Run</h2><div class="line">${r.run.map(c => `<code>${esc(c)}</code>`).join(' ')}</div>` : ''}
|
|
602
|
+
${r.setup.length ? `<h2>Setup</h2><div class="line">${r.setup.map(c => `<code>${esc(c)}</code>`).join(' ')}</div>` : ''}
|
|
603
|
+
${ov.readme ? `<h2>README</h2><pre class="readme">${esc(ov.readme)}</pre>` : ''}</div>
|
|
604
|
+
<aside>
|
|
605
|
+
${ov.languages.length ? `<h2>Languages</h2><div class="lbar">${ov.languages.map((l, i) => `<i style="width:${l.bytes / tot * 100}%;background:${LANG_COLOR[i % 8]}" title="${esc(l.name)}"></i>`).join('')}</div>
|
|
606
|
+
<div style="margin-top:8px">${ov.languages.slice(0, 6).map((l, i) => `<div class="lg"><span><span class="dot" style="background:${LANG_COLOR[i % 8]}"></span>${esc(l.name)}</span><span class="meta">${(l.bytes / tot * 100).toFixed(1)}%</span></div>`).join('')}</div>` : ''}
|
|
607
|
+
<h2>Contributors</h2>${ov.contributors.map(c => `<div class="lg"><span>${person(c)}</span><span class="meta">${c.commits}</span></div>`).join('') || '<div class="meta">none</div>'}
|
|
608
|
+
<h2>Tags</h2>${ov.tags.slice(0, 6).map(t => `<div class="lg"><span>${esc(t.name)}</span><span class="meta">${ago(t.time)}</span></div>`).join('') || '<div class="meta">no tags</div>'}
|
|
609
|
+
<h2>Most changed files</h2>${ov.hot_files.slice(0, 8).map(f => `<div class="sr">${go(f.file, f.changes)}</div>`).join('') || '<div class="meta">none</div>'}
|
|
610
|
+
</aside></div>`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function refOptions(r) {
|
|
614
|
+
const w = W(r), names = ['HEAD', w.default, r.branch, ...w.branches.map(b => b.name)].filter(Boolean);
|
|
615
|
+
return [...new Set(names)].map(n => `<option ${n === pg.ref ? 'selected' : ''}>${esc(n)}</option>`).join('');
|
|
616
|
+
}
|
|
617
|
+
const IC = {
|
|
618
|
+
dir: '<svg class="ic" width="15" height="15" viewBox="0 0 16 16"><path d="M1.5 3.5h4.2l1.4 1.6h7.4v7.4h-13z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>',
|
|
619
|
+
file: '<svg class="ic" width="15" height="15" viewBox="0 0 16 16"><path d="M3.5 1.5h6l3 3v10h-9zM9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>',
|
|
620
|
+
up: '<svg class="ic" width="15" height="15" viewBox="0 0 16 16"><path d="M8 13V3M4 7l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
621
|
+
};
|
|
622
|
+
async function pFiles(r) {
|
|
623
|
+
const {entries} = await jget('/api/tree', {path: r.path, ref: pg.ref, dir: pg.dir});
|
|
624
|
+
const parts = pg.dir ? pg.dir.split('/') : [];
|
|
625
|
+
const crumbs = `<a class="br" data-dir="">${esc(r.name)}</a>` + parts.map((p, i) => ` / <a class="br" data-dir="${esc(parts.slice(0, i + 1).join('/'))}">${esc(p)}</a>`).join('');
|
|
626
|
+
let viewer = '<div class="panel"><div class="bar2"><span class="meta">Pick a file to read it.</span></div></div>';
|
|
627
|
+
if (pg.file) {
|
|
628
|
+
let inner;
|
|
629
|
+
try {
|
|
630
|
+
const f = await jget('/api/file', {path: r.path, ref: pg.ref, file: pg.file});
|
|
631
|
+
const lines = f.binary ? [] : f.text.split('\n').slice(0, 6000);
|
|
632
|
+
inner = `<div class="bar2">${IC.file}<b class="crumbs" style="color:var(--fg)">${esc(pg.file)}</b><span class="meta">${f.binary ? 'binary · ' : lines.length + ' lines · '}${size(f.size)}${f.truncated || lines.length >= 6000 ? ' · truncated' : ''}</span>
|
|
633
|
+
<button class="go" data-copy="${esc(pg.file)}">Copy path</button></div>` +
|
|
634
|
+
(f.binary ? '<div class="empty" style="padding:16px">Binary file, not shown.</div>'
|
|
635
|
+
: `<div class="code">${lines.map((l, i) => `<div class="cl ${pg.line === i + 1 ? 'hit' : ''}" ${pg.line === i + 1 ? 'data-hit' : ''}><i>${i + 1}</i><span>${esc(l) || ' '}</span></div>`).join('')}</div>`);
|
|
636
|
+
} catch (e) { inner = `<div class="bar2"><span class="meta">${esc(e.message)}</span></div>`; }
|
|
637
|
+
viewer = `<div class="panel">${inner}</div>`;
|
|
638
|
+
}
|
|
639
|
+
const up = pg.dir ? `<div class="ent" data-dir="${esc(parts.slice(0, -1).join('/'))}">${IC.up}<span class="nm">..</span></div>` : '';
|
|
640
|
+
return `<div class="split"><div class="panel">
|
|
641
|
+
<div class="bar2"><select data-ref>${refOptions(r)}</select><span class="crumbs">${crumbs}</span></div>
|
|
642
|
+
<div class="list">${up}${entries.map(e => { const full = (pg.dir ? pg.dir + '/' : '') + e.name;
|
|
643
|
+
return e.type === 'tree' ? `<div class="ent dir" data-dir="${esc(full)}">${IC.dir}<span class="nm">${esc(e.name)}</span></div>`
|
|
644
|
+
: `<div class="ent ${pg.file === full ? 'sel' : ''}" data-file="${esc(full)}">${IC.file}<span class="nm">${esc(e.name)}</span><span class="meta">${size(e.size)}</span></div>`; }).join('') || '<div class="empty" style="padding:12px">Empty folder.</div>'}</div></div>
|
|
645
|
+
<div>${viewer}</div></div>`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function pHistory(r) {
|
|
649
|
+
const {commits} = pg.cache.graph ||= await jget('/api/graph', {path: r.path, n: 300});
|
|
650
|
+
const {rows, maxw} = layoutGraph(commits);
|
|
651
|
+
return `<div class="split hist"><div class="gl" id="glist">${rows.map(x => drawRow(x, maxw, pg.focus)).join('') || '<div class="empty">No commits.</div>'}</div>
|
|
652
|
+
<div class="cp" id="cpanel"><div class="meta">Click a commit to see what changed.</div></div></div>`;
|
|
653
|
+
}
|
|
654
|
+
function renderDiff(diff) {
|
|
655
|
+
const blocks = diff.split(/^diff --git /m).filter(Boolean);
|
|
656
|
+
return blocks.map((b, i) => {
|
|
657
|
+
const lines = b.split('\n'), name = (lines[0].match(/ b\/(.*)$/) || [])[1] || lines[0];
|
|
658
|
+
const body = lines.slice(1).map(l => `<div class="${l.startsWith('+') && !l.startsWith('+++') ? 'add' : l.startsWith('-') && !l.startsWith('---') ? 'del' : l.startsWith('@@') ? 'hunk' : ''}">${esc(l) || ' '}</div>`).join('');
|
|
659
|
+
return `<details ${i < 3 ? 'open' : ''}><summary>${esc(name)}</summary><div class="diff code">${body}</div></details>`;
|
|
660
|
+
}).join('');
|
|
661
|
+
}
|
|
662
|
+
async function loadCommit(hash) {
|
|
663
|
+
document.querySelectorAll('#glist .gr').forEach(x => x.classList.toggle('sel', x.dataset.hash === hash || x.dataset.hash.startsWith(hash)));
|
|
664
|
+
const box = $('#cpanel'); box.innerHTML = '<div class="meta">Loading…</div>';
|
|
665
|
+
try {
|
|
666
|
+
const c = await jget('/api/commit', {path: pg.path, hash: hash.slice(0, 40)});
|
|
667
|
+
if (!$('#cpanel') || !c.hash.startsWith(hash.slice(0, 7))) return;
|
|
668
|
+
const [subject, ...rest] = c.message.split('\n');
|
|
669
|
+
$('#cpanel').innerHTML = `<b>${esc(subject)}</b><pre class="msg">${esc(rest.join('\n').trim())}</pre>
|
|
670
|
+
<div class="meta">${esc(c.author)} · ${new Date(c.time * 1000).toLocaleString()} · <code>${esc(c.hash.slice(0, 10))}</code></div>
|
|
671
|
+
<div class="meta" style="margin:8px 0">${c.files.length} files: ${c.files.slice(0, 40).map(f => `<a class="br mono" data-pp="${esc(pg.path)}" data-pt="files" data-pf="${esc(f.file)}" title="open current version">${esc(f.file)}</a> <span style="color:var(--ok)">+${f.added ?? '?'}</span> <span style="color:var(--warn)">−${f.deleted ?? '?'}</span>`).join('<br>')}</div>
|
|
672
|
+
${renderDiff(c.diff)}${c.diff_truncated ? '<div class="meta">diff truncated</div>' : ''}`;
|
|
673
|
+
} catch (e) { box.innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function pBranches(r) {
|
|
677
|
+
const w = W(r), st = b => b.state === 'local-only' ? `<span class="tag warn">${b.gone ? 'upstream gone' : b.ahead ? '↑' + b.ahead + ' unpushed' : 'local only'}</span>` : '<span class="tag">pushed, not merged</span>';
|
|
678
|
+
return `<div class="meta">Default branch: <b>${esc(w.default || '?')}</b> · current: <b>${esc(r.branch)}</b> · ${w.merged_branches} more branches are already merged</div>
|
|
679
|
+
<h2>Unmerged branches (${w.branches.length})</h2>${w.branches.map(b => `<div class="sr"><a class="br" data-pp="${esc(r.path)}" data-pt="history" data-pfocus="${esc(b.name)}">${esc(b.name)}</a> ${st(b)} <span class="meta">${ago(b.time)}</span></div>`).join('') || '<div class="meta">none</div>'}
|
|
680
|
+
<h2>Stashes (${w.stashes.length})</h2>${w.stashes.map(s => `<div class="sr"><code>git stash show -p ${esc(s.ref)}</code> <span class="meta">${esc(s.message)} · ${ago(s.time)}</span></div>`).join('') || '<div class="meta">none</div>'}
|
|
681
|
+
<h2>Worktrees (${w.worktrees.length})</h2>${w.worktrees.map(t => `<div class="sr"><code>${esc(home(t.path))}</code> <span class="meta">${esc(t.branch || 'detached')}</span>${t.missing ? ' <span class="tag warn">folder missing</span>' : ''}</div>`).join('') || '<div class="meta">none</div>'}`;
|
|
682
|
+
}
|
|
683
|
+
function pDeps(r) {
|
|
684
|
+
const idx = depIndex(), ds = r.deps || [];
|
|
685
|
+
if (!ds.length) return '<div class="meta">No dependency manifest found (package.json, pyproject.toml, requirements.txt, go.mod, Cargo.toml, composer.json).</div>';
|
|
686
|
+
return '<table>' + ds.map(d => { const p = idx.get(d.eco + ':' + d.name), n = p ? repoCount(p) : 1;
|
|
687
|
+
return `<tr><td><b>${esc(d.name)}</b> <span class="tag">${esc(d.eco)}</span>${d.dev ? ' <span class="tag">dev</span>' : ''}${d.where ? ` <span class="meta">in ${esc(d.where)}/</span>` : ''}</td>
|
|
688
|
+
<td><code>${esc(d.spec || 'any')}</code></td><td class="r">${n > 1 ? `<a class="br" data-deplink="${esc(d.name)}">also in ${n - 1} other repo${n > 2 ? 's' : ''}</a>` : '<span class="meta">only here</span>'}</td></tr>`; }).join('') + '</table>';
|
|
689
|
+
}
|
|
690
|
+
function afterPage(tab) {
|
|
691
|
+
if (tab === 'history') {
|
|
692
|
+
if (pg.focus) $('#glist [data-focus]')?.scrollIntoView({block: 'center'});
|
|
693
|
+
if (pg.commit) { $('#glist [data-hash^="' + pg.commit.slice(0, 40) + '"]')?.scrollIntoView({block: 'center'}); loadCommit(pg.commit); }
|
|
694
|
+
}
|
|
695
|
+
if (tab === 'files') $('#pbody [data-hit]')?.scrollIntoView({block: 'center'});
|
|
696
|
+
}
|
|
697
|
+
$('#page').addEventListener('click', e => {
|
|
698
|
+
const t = e.target;
|
|
699
|
+
const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard?.writeText(cp.dataset.copy); return toast('Copied'); }
|
|
700
|
+
if (t.closest('#pclose')) return closePage();
|
|
701
|
+
const tabEl = t.closest('[data-ptab]'); if (tabEl) { pg.tab = tabEl.dataset.ptab; pg.focus = ''; pg.commit = null; return renderPage(); }
|
|
702
|
+
const dir = t.closest('[data-dir]'); if (dir) { pg.dir = dir.dataset.dir; pg.file = null; pg.line = null; return renderPage(); }
|
|
703
|
+
const file = t.closest('[data-file]'); if (file) { pg.file = file.dataset.file; pg.line = null; return renderPage(); }
|
|
704
|
+
const gr = t.closest('.gr'); if (gr) return loadCommit(gr.dataset.hash);
|
|
705
|
+
const dl = t.closest('[data-deplink]'); if (dl) { ui.back = {path: pg.path, tab: pg.tab, under: ui.tab}; closePage(); ui.tab = 'search'; saveUi(); render(); showBack(); return searchFor(dl.dataset.deplink, ['packages']); }
|
|
706
|
+
});
|
|
707
|
+
$('#page').addEventListener('change', e => { if (e.target.hasAttribute('data-ref')) { pg.ref = e.target.value; renderPage(); } });
|
|
708
|
+
|
|
709
|
+
/* ---------- settings ---------- */
|
|
710
|
+
let cfg = {settings: {}, editors: [], terminals: [], agents: []};
|
|
711
|
+
function applyTheme(t) {
|
|
712
|
+
const root = document.documentElement;
|
|
713
|
+
if (t === 'dark' || t === 'light') root.dataset.theme = t; else delete root.dataset.theme;
|
|
714
|
+
try { t === 'dark' || t === 'light' ? localStorage.setItem('gr-theme', t) : localStorage.removeItem('gr-theme'); } catch {}
|
|
715
|
+
}
|
|
716
|
+
async function loadCfg() { try { cfg = await api('/api/settings'); applyTheme(cfg.settings.theme); } catch {} }
|
|
717
|
+
function renderCfg() {
|
|
718
|
+
const st = cfg.settings, cur = st.editor || '';
|
|
719
|
+
const opt = (name, val, label, checked) => `<label class="opt"><input type="radio" name="${name}" value="${esc(val)}" ${checked ? 'checked' : ''}>${esc(label)}</label>`;
|
|
720
|
+
const first = cfg.editors[0]?.id;
|
|
721
|
+
const theme = st.theme || 'system';
|
|
722
|
+
$('#sbody').innerHTML = `<h2 style="margin-top:0">Appearance</h2>
|
|
723
|
+
<div class="seg">${[['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([v, l]) => `<label class="segopt"><input type="radio" name="theme" value="${v}" ${theme === v ? 'checked' : ''}><span>${l}</span></label>`).join('')}</div>
|
|
724
|
+
<h2>Editor</h2>
|
|
725
|
+
${cfg.editors.map(e => opt('editor', e.id, e.label, cur === e.id || (!cur && e.id === first))).join('') || '<div class="meta">No known editor found on this machine.</div>'}
|
|
726
|
+
${opt('editor', 'custom', 'Custom command', cur === 'custom')}
|
|
727
|
+
<input id="ecustom" placeholder="for example: code -r" value="${esc(st.editor_custom || '')}" style="width:100%;margin-top:4px">
|
|
728
|
+
<h2>Terminal</h2>
|
|
729
|
+
${cfg.terminals.length ? `<select id="eterm" style="width:100%">${cfg.terminals.map(t => `<option value="${esc(t.id)}" ${st.terminal === t.id ? 'selected' : ''}>${esc(t.label)}</option>`).join('')}</select>` : '<div class="meta">No terminal found.</div>'}
|
|
730
|
+
<h2>AI agent</h2>
|
|
731
|
+
${cfg.agent_override ? '<div class="meta" style="margin-bottom:6px">Overridden by --agent-cmd or GITROSTER_AGENT.</div>' : ''}
|
|
732
|
+
${cfg.agents.map(a => a.installed ? opt('agent', a.id, a.label, st.agent === a.id || (!st.agent && a.id === (cfg.agents.find(x => x.installed) || {}).id))
|
|
733
|
+
+ `<div class="meta hint">install: <code>${esc(a.install)}</code></div>`
|
|
734
|
+
: `<label class="opt dim"><input type="radio" disabled>${esc(a.label)} <span class="tag">not installed</span></label><div class="meta hint">install: <code>${esc(a.install)}</code></div>`).join('')}
|
|
735
|
+
${opt('agent', 'custom', 'Custom command', st.agent === 'custom')}
|
|
736
|
+
<input id="acustom" placeholder="reads the prompt on stdin, e.g. my-agent --no-tools" value="${esc(st.agent_custom || '')}" style="width:100%;margin-top:4px">
|
|
737
|
+
<div class="meta" style="margin-top:6px">Summaries run the CLI you pick with tools turned off. It receives the README, a file list, dependency names and recent commit titles, and nothing else. Where that text goes depends on the agent: Claude, Codex and Gemini are cloud services.</div>
|
|
738
|
+
<div class="meta" style="margin-top:12px">Editors and terminals: only those installed on this machine are listed. Agents that are not installed are greyed out. Saved automatically.</div>
|
|
739
|
+
<h2>About</h2>
|
|
740
|
+
<div class="about"><b>gitroster</b> <span class="meta">v${esc(cfg.version || '')} · MIT</span>
|
|
741
|
+
<ul>
|
|
742
|
+
<li>Finds git repos in the folders you started it with and reads them with git. It never changes your repos.</li>
|
|
743
|
+
<li>Ports and processes come from your operating system and are matched to repos by working directory.</li>
|
|
744
|
+
<li>Search, history, files and diffs run git on demand. Everything stays on this machine.</li>
|
|
745
|
+
<li>AI summaries are optional and only run the agent CLI you chose.</li>
|
|
746
|
+
<li>Notes, pins and these settings are saved in your user config folder.</li>
|
|
747
|
+
</ul>
|
|
748
|
+
<div class="meta">Made by <a href="https://github.com/firatsahn" target="_blank" rel="noopener">firatsahn</a> ·
|
|
749
|
+
<a href="https://github.com/firatsahn/gitroster/graphs/contributors" target="_blank" rel="noopener">all contributors</a> ·
|
|
750
|
+
<a href="https://github.com/firatsahn/gitroster" target="_blank" rel="noopener">source</a></div></div>`;
|
|
751
|
+
}
|
|
752
|
+
async function saveCfg(patch) { try { cfg.settings = (await api('/api/settings', patch)).settings; toast('Saved'); } catch (e) { toast(e.message); } }
|
|
753
|
+
async function openSettings() {
|
|
754
|
+
const had = cfg.version; // loaded in the background at startup, so this is normally instant
|
|
755
|
+
if (had) renderCfg(); else $('#sbody').innerHTML = '<div class="meta">Looking for editors, terminals and agents…</div>';
|
|
756
|
+
$('#settings').classList.add('on');
|
|
757
|
+
if (!had) { await loadCfg(); renderCfg(); }
|
|
758
|
+
}
|
|
759
|
+
$('#cfg').addEventListener('click', openSettings);
|
|
760
|
+
$('#sclose').addEventListener('click', () => $('#settings').classList.remove('on'));
|
|
761
|
+
$('#settings').addEventListener('click', e => { if (e.target.id === 'settings') $('#settings').classList.remove('on'); });
|
|
762
|
+
$('#settings').addEventListener('change', e => {
|
|
763
|
+
if (e.target.name === 'theme') { applyTheme(e.target.value); return saveCfg({theme: e.target.value}); }
|
|
764
|
+
if (e.target.name === 'editor') saveCfg({editor: e.target.value, editor_custom: $('#ecustom').value});
|
|
765
|
+
if (e.target.id === 'eterm') saveCfg({terminal: e.target.value});
|
|
766
|
+
if (e.target.id === 'ecustom') saveCfg({editor: 'custom', editor_custom: e.target.value});
|
|
767
|
+
if (e.target.name === 'agent') saveCfg({agent: e.target.value, agent_custom: $('#acustom').value});
|
|
768
|
+
if (e.target.id === 'acustom') saveCfg({agent: 'custom', agent_custom: e.target.value});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
/* ---------- events ---------- */
|
|
772
|
+
async function act(fn) { try { await fn(); } catch (e) { toast(e.message); } render(); }
|
|
773
|
+
|
|
774
|
+
document.addEventListener('click', e => {
|
|
775
|
+
const t = e.target, row = t.closest('.row'), path = row && row.dataset.path;
|
|
776
|
+
if (t.closest('[data-tab]')) { ui.tab = t.closest('[data-tab]').dataset.tab; ui.back = null; showBack(); saveUi(); return render(); }
|
|
777
|
+
const pp = t.closest('[data-pp]');
|
|
778
|
+
if (pp && !t.closest('#page [data-ptab]')) { const d = pp.dataset; return openPage(d.pp, d.pt || 'overview', {file: d.pf, line: +d.pl || null, commit: d.pc, focus: d.pfocus}); }
|
|
779
|
+
if (t.dataset.sq !== undefined) return searchFor(t.dataset.sq, null, t.dataset.regex === '1');
|
|
780
|
+
if (t.hasAttribute('data-back')) { const b = ui.back; ui.back = null; showBack(); ui.tab = b.under || 'repos'; saveUi(); render(); return openPage(b.path, b.tab); }
|
|
781
|
+
if (t.hasAttribute('data-more')) { ui.more = !ui.more; saveUi(); return render(); }
|
|
782
|
+
const ai = t.closest('[data-ai]');
|
|
783
|
+
if (ai) { const p = path || pg.path, rr = repoOf(p); return ai.dataset.ai === 'icon' && aiOf(rr) && !aiBusy(rr) ? openPage(p, 'overview') : runAi(p); }
|
|
784
|
+
if (t.dataset.open && !row) return act(async () => { const o = await api('/api/open', {path: pg.path, target: t.dataset.open}); toast('Opened in ' + o.with); });
|
|
785
|
+
if (t.dataset.chip) { ui.chip = t.dataset.chip; saveUi(); return render(); }
|
|
786
|
+
if (t.dataset.kill) return act(async () => {
|
|
787
|
+
if (!confirm(`Stop process ${t.dataset.kill} on port ${t.dataset.port}?`)) return;
|
|
788
|
+
await api('/api/kill', {pid: +t.dataset.kill}); toast('Stopped'); setTimeout(pollPorts, 600);
|
|
789
|
+
});
|
|
790
|
+
if (t.tagName === 'CODE') { navigator.clipboard?.writeText(t.textContent); return toast('Copied'); }
|
|
791
|
+
if (!row) return;
|
|
792
|
+
const r = S.repos.find(x => x.path === path);
|
|
793
|
+
if (t.dataset.open) return act(async () => { const o = await api('/api/open', {path, target: t.dataset.open}); toast('Opened in ' + o.with); });
|
|
794
|
+
if (t.hasAttribute('data-pin')) return act(async () => { S.meta[path] = await api('/api/meta', {path, pinned: !meta(r).pinned}); });
|
|
795
|
+
if (t.hasAttribute('data-ignore')) return act(async () => { S.meta[path] = await api('/api/meta', {path, ignored: !meta(r).ignored}); });
|
|
796
|
+
if (t.dataset.start) return act(async () => {
|
|
797
|
+
if (!confirm(`Run in ${home(path)}:\n\n${t.dataset.start}`)) return;
|
|
798
|
+
ui.busy.add(path); render();
|
|
799
|
+
try { await api('/api/start', {path, cmd: t.dataset.start}); toast('Started'); } finally { ui.busy.delete(path); }
|
|
800
|
+
for (const ms of [1500, 4000]) setTimeout(async () => { ui.logs[path] = (await api('/api/log?path=' + encodeURIComponent(path))).log; pollPorts(); render(); }, ms);
|
|
801
|
+
});
|
|
802
|
+
if (t.closest('[data-toggle]') && !t.closest('button')) {
|
|
803
|
+
ui.open.has(path) ? ui.open.delete(path) : ui.open.add(path); render();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
document.addEventListener('focusout', e => {
|
|
807
|
+
if (!e.target.hasAttribute('data-note')) return;
|
|
808
|
+
const path = e.target.closest('.row').dataset.path;
|
|
809
|
+
api('/api/meta', {path, note: e.target.value}).then(m => { S.meta[path] = m; }).catch(err => toast(err.message));
|
|
810
|
+
});
|
|
811
|
+
document.addEventListener('keydown', e => {
|
|
812
|
+
if (e.key === '/' && ui.tab === 'search' && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); return $('#sq').focus(); }
|
|
813
|
+
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { e.preventDefault(); $('#q').focus(); }
|
|
814
|
+
if (e.key === 'Escape' && $('#settings').classList.contains('on')) return $('#settings').classList.remove('on');
|
|
815
|
+
if (e.key === 'Escape' && $('#page').classList.contains('on')) return closePage();
|
|
816
|
+
if (e.key === 'Escape') { $('#q').value = ''; $('#q').blur(); render(); }
|
|
817
|
+
});
|
|
818
|
+
$('#q').addEventListener('input', render);
|
|
819
|
+
function clearSearch() { clearTimeout(searchTimer); $('#sq').value = ''; $('#sclear').classList.add('hide'); searchSeq++; $('#sres').innerHTML = searchHome(); $('#sq').focus(); }
|
|
820
|
+
$('#sclear').addEventListener('click', clearSearch);
|
|
821
|
+
$('#sq').addEventListener('input', () => { $('#sclear').classList.toggle('hide', !$('#sq').value); clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 350); });
|
|
822
|
+
$('#sq').addEventListener('keydown', e => { if (e.key === 'Enter') { clearTimeout(searchTimer); runSearch(); } if (e.key === 'Escape' && $('#sq').value) { e.stopPropagation(); clearSearch(); } });
|
|
823
|
+
document.querySelectorAll('[data-scope], #sre').forEach(x => x.addEventListener('change', runSearch));
|
|
824
|
+
$('#sort').addEventListener('change', () => { saveUi(); render(); });
|
|
825
|
+
$('#re').addEventListener('click', rescan);
|
|
826
|
+
try { const s = JSON.parse(localStorage.getItem('gr-ui') || '{}').sort; if (s) $('#sort').value = s; } catch {}
|
|
827
|
+
$('#popen').innerHTML = openBtns();
|
|
828
|
+
loadCfg(); // editors, terminals and theme: ready before Settings is opened
|
|
829
|
+
load();
|
|
830
|
+
setInterval(pollPorts, 5000);
|
|
831
|
+
</script>
|
|
832
|
+
</body>
|
|
833
|
+
</html>
|