stackdeck 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/index.html ADDED
@@ -0,0 +1,1134 @@
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>Stackdeck</title>
7
+ <link rel="icon" type="image/svg+xml" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="16" y="44" width="34" height="10" rx="5" fill="%2357534b"/><rect x="11" y="29" width="34" height="10" rx="5" fill="%238a8375"/><rect x="6" y="14" width="34" height="10" rx="5" fill="%23dfa662"/><circle cx="48" cy="19" r="4.5" fill="%235ec488"/><circle cx="48" cy="19" r="7.5" fill="%235ec488" opacity="0.25"/></svg>'>
8
+ <style>
9
+ :root {
10
+ --bg: oklch(0.145 0.008 75);
11
+ --surface: oklch(0.18 0.009 75);
12
+ --surface-2: oklch(0.215 0.011 75);
13
+ --border: oklch(0.27 0.012 75);
14
+ --hairline: oklch(0.23 0.010 75);
15
+ --text: oklch(0.93 0.010 85);
16
+ --dim: oklch(0.66 0.014 80);
17
+ --faint: oklch(0.47 0.012 80);
18
+ --accent: oklch(0.78 0.115 68);
19
+ --accent-dim:oklch(0.78 0.115 68 / .14);
20
+ --green: oklch(0.74 0.145 152);
21
+ --green-dim: oklch(0.74 0.145 152 / .13);
22
+ --red: oklch(0.66 0.17 26);
23
+ --red-dim: oklch(0.66 0.17 26 / .13);
24
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
25
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html { color-scheme: dark; }
29
+ body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 var(--sans); }
30
+
31
+ header {
32
+ display:flex; align-items:baseline; gap:14px;
33
+ padding:22px 28px 0; max-width:1000px; margin:0 auto;
34
+ }
35
+ header h1 { font-size:16px; font-weight:650; margin:0; letter-spacing:.01em; }
36
+ header h1 span { color:var(--accent); }
37
+ #summary { color:var(--dim); font-size:12.5px; }
38
+ header .spacer { flex:1; }
39
+ header .paths { color:var(--faint); font-size:11px; font-family:var(--mono); }
40
+
41
+ main { max-width:1000px; margin:0 auto; padding:10px 28px 96px; }
42
+
43
+ .section-label {
44
+ margin:36px 2px 10px; font-size:11px; font-weight:600; letter-spacing:.1em;
45
+ text-transform:uppercase; color:var(--faint);
46
+ display:flex; align-items:center; gap:10px;
47
+ }
48
+ .section-label .count { color:var(--dim); font-weight:500; letter-spacing:0; text-transform:none; }
49
+ .section-label button { font-size:11.5px; padding:3px 9px; }
50
+ .section-label input {
51
+ margin-left:auto; width:220px; font-size:12.5px; letter-spacing:0; text-transform:none; font-weight:400;
52
+ }
53
+
54
+ .ledger { background:var(--surface); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
55
+
56
+ .row { border-top:1px solid var(--hairline); transition:background .15s ease-out; }
57
+ .row:first-child { border-top:none; }
58
+ .row:hover { background:var(--surface-2); }
59
+ .row-main { display:flex; align-items:center; gap:12px; padding:11px 16px; }
60
+
61
+ /* hollow = stopped, filled+halo = running */
62
+ .dot { width:8px; height:8px; border-radius:50%; background:transparent;
63
+ border:1.5px solid var(--faint); flex:none; }
64
+ .dot.up { background:var(--green); border-color:var(--green); box-shadow:0 0 0 3px var(--green-dim); }
65
+
66
+ .svc-name { font-weight:600; font-size:13.5px; min-width:96px; }
67
+ .addr { font-family:var(--mono); font-size:12px; color:var(--dim); white-space:nowrap; }
68
+ .grow { flex:1; min-width:0; }
69
+ .cmdline {
70
+ font-family:var(--mono); font-size:11.5px; color:var(--faint);
71
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
72
+ }
73
+ .cmdline b { color:var(--dim); font-weight:500; }
74
+
75
+ .badge {
76
+ font-size:10.5px; font-weight:600; letter-spacing:.03em; padding:2px 7px;
77
+ border-radius:5px; white-space:nowrap;
78
+ }
79
+ .badge.ext { color:var(--accent); background:var(--accent-dim); }
80
+ .badge.dirty { color:var(--accent); background:var(--accent-dim); }
81
+ .badge.git { color:var(--dim); background:oklch(0.30 0.012 75 / .5); font-family:var(--mono); font-weight:500; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
82
+ .badge.plain { color:var(--faint); background:oklch(0.30 0.012 75 / .35); }
83
+ .badge.cfgd { color:var(--green); background:var(--green-dim); }
84
+ .badge.crash { color:var(--red); background:var(--red-dim); }
85
+ a.badge.link { color:var(--accent); background:var(--accent-dim); text-decoration:none; }
86
+ a.badge.link:hover { text-decoration:underline; }
87
+ .logfilter { width:200px; margin:0 0 6px; font-size:11.5px; padding:3px 8px; }
88
+
89
+ select, input, textarea {
90
+ background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:7px;
91
+ padding:5px 9px; font-family:var(--mono); font-size:12px;
92
+ }
93
+ select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible {
94
+ outline:2px solid var(--accent); outline-offset:1px;
95
+ }
96
+ select:disabled { opacity:.4; cursor:not-allowed; }
97
+
98
+ /* fixed-width slots keep every row's controls in the same columns */
99
+ .branch-slot { width:220px; flex:none; display:flex; justify-content:flex-end; }
100
+ .branch-slot select { width:100%; }
101
+ .branch-slot select.dirty { border-color:var(--accent); }
102
+ .row-actions { display:flex; gap:2px; flex:none; align-items:center; }
103
+ .row-actions .primary { width:74px; text-align:center; }
104
+ .addr { width:110px; flex:none; text-align:right; }
105
+
106
+ /* Secondary actions stay quiet until the row (or section head) is engaged —
107
+ the board reads as status, not as a wall of buttons. */
108
+ .row-actions .ghost, .group-head .actions button, .row .add {
109
+ opacity:0; transition:opacity .15s ease-out;
110
+ }
111
+ .row:hover .row-actions .ghost, .row:focus-within .row-actions .ghost,
112
+ .group-head:hover .actions button, .group-head:focus-within .actions button,
113
+ .row:hover .add, .row:focus-within .add { opacity:1; }
114
+
115
+ /* touch devices have no hover — keep actions visible there */
116
+ @media (hover: none) {
117
+ .row-actions .ghost, .group-head .actions button, .row .add { opacity:1; }
118
+ }
119
+ @media (prefers-reduced-motion: reduce) {
120
+ *, *::before, *::after { transition:none !important; animation:none !important; }
121
+ }
122
+ textarea { width:100%; resize:vertical; min-height:34px; }
123
+
124
+ button {
125
+ font:600 12.5px var(--sans); color:var(--text);
126
+ background:var(--surface-2); border:1px solid var(--border); border-radius:7px;
127
+ padding:5px 13px; cursor:pointer;
128
+ transition:border-color .15s ease-out, background .15s ease-out, color .15s ease-out;
129
+ }
130
+ button:hover { border-color:var(--dim); }
131
+ button:active { transform:translateY(0.5px); }
132
+ button:disabled { opacity:.4; cursor:not-allowed; }
133
+ button.start { color:var(--green); background:var(--green-dim); border-color:transparent; }
134
+ button.start:hover { background:var(--green); color:oklch(0.15 0.02 152); }
135
+ button.kill { color:var(--red); background:var(--red-dim); border-color:transparent; }
136
+ button.kill:hover { background:var(--red); color:oklch(0.97 0.01 26); }
137
+ button.ghost { background:transparent; border-color:transparent; color:var(--dim); font-weight:500; }
138
+ button.ghost:hover { color:var(--text); border-color:var(--border); }
139
+ button.add { color:var(--accent); background:var(--accent-dim); border-color:transparent; }
140
+ button.add:hover { background:var(--accent); color:oklch(0.16 0.02 68); }
141
+
142
+ .drawer { display:none; padding:4px 16px 14px 36px; }
143
+ .drawer.open { display:block; }
144
+ .fields { display:grid; grid-template-columns:64px 1fr; gap:7px 12px; align-items:center; max-width:720px; }
145
+ .fields label { color:var(--faint); font-size:11.5px; text-align:right; }
146
+
147
+ .logs {
148
+ background:oklch(0.10 0.006 75); border:1px solid var(--hairline); border-radius:8px;
149
+ padding:10px 12px; height:250px; overflow:auto; overscroll-behavior:contain;
150
+ font-family:var(--mono); font-size:11.5px; line-height:1.55; white-space:pre-wrap; color:var(--dim);
151
+ }
152
+ .logs:empty::before { content:"waiting for output…"; color:var(--faint); font-style:italic; }
153
+ .logs::-webkit-scrollbar { width:8px; }
154
+ .logs::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
155
+ .logs::-webkit-scrollbar-track { background:transparent; }
156
+
157
+ .empty { padding:26px 16px; text-align:center; color:var(--faint); font-size:12.5px; }
158
+
159
+ /* drag & drop */
160
+ .handle {
161
+ color:var(--faint); cursor:grab; font-size:13px; letter-spacing:-1px;
162
+ padding:2px 2px 2px 0; user-select:none; flex:none;
163
+ }
164
+ .handle:hover { color:var(--dim); }
165
+ .row.dragging { opacity:.35; }
166
+ .row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
167
+ .ledger.drop-into { border-color:var(--accent); background:var(--accent-dim); }
168
+ .group-block { margin-bottom:18px; }
169
+ .group-head {
170
+ display:flex; align-items:center; gap:8px; margin:0 2px 6px;
171
+ font-size:11.5px; font-weight:600; color:var(--dim); letter-spacing:.04em;
172
+ }
173
+ .group-head .count { color:var(--faint); font-weight:500; }
174
+ .group-head button { padding:1px 7px; font-size:11px; }
175
+ .group-head .actions { margin-left:auto; display:flex; gap:4px; }
176
+ .hiddenItem { opacity:.45; }
177
+ .group-head .title { display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
178
+ .group-head .title:hover { color:var(--text); }
179
+ .chev { display:inline-block; transition:transform .15s ease-out; font-size:10px; color:var(--faint); }
180
+ .chev.closed { transform:rotate(-90deg); }
181
+
182
+ /* settings: folder tree */
183
+ .cfg-label { font-size:11.5px; font-weight:600; color:var(--dim); letter-spacing:.04em; margin:2px 0 8px; }
184
+ .cfg-label .count { color:var(--faint); font-weight:500; }
185
+ .root-row {
186
+ display:flex; align-items:center; gap:10px; padding:6px 0;
187
+ font-family:var(--mono); font-size:12.5px;
188
+ }
189
+ .root-row .chev { cursor:pointer; padding:4px; }
190
+ .child-grid {
191
+ display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));
192
+ gap:2px 14px; padding:6px 0 12px 24px;
193
+ }
194
+ .child-grid label {
195
+ display:flex; align-items:center; gap:7px; font-family:var(--mono); font-size:12px;
196
+ color:var(--text); cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
197
+ }
198
+ .child-grid label.off { color:var(--faint); text-decoration:line-through; }
199
+ .child-grid input { accent-color:var(--accent); }
200
+ .browser {
201
+ border:1px solid var(--border); border-radius:8px; padding:10px 12px; margin:8px 0;
202
+ max-width:560px; background:var(--bg);
203
+ }
204
+ .browser .crumb { font-family:var(--mono); font-size:12px; color:var(--dim); margin-bottom:8px; word-break:break-all; }
205
+ .browser .dirs { max-height:220px; overflow:auto; display:grid; gap:1px; }
206
+ .browser .dirs button {
207
+ text-align:left; background:transparent; border-color:transparent; color:var(--text);
208
+ font:500 12.5px var(--mono); padding:3px 8px;
209
+ }
210
+ .browser .dirs button:hover { background:var(--surface-2); }
211
+ .cat-list { display:grid; gap:2px; max-width:380px; }
212
+ .cat-row {
213
+ display:flex; align-items:center; gap:9px; padding:5px 10px;
214
+ border:1px solid var(--hairline); border-radius:7px; background:var(--bg);
215
+ font-size:12.5px; cursor:grab; user-select:none;
216
+ }
217
+ .cat-row .count { color:var(--faint); margin-left:auto; font-size:11.5px; }
218
+ .cat-row.dragging { opacity:.35; }
219
+ .cat-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
220
+ .cat-row.pinned { color:var(--faint); cursor:default; border-style:dashed; }
221
+
222
+ .toast {
223
+ position:fixed; bottom:22px; left:50%; transform:translate(-50%, 8px);
224
+ background:var(--surface-2); border:1px solid var(--border); border-radius:9px;
225
+ padding:10px 18px; max-width:70%; font-size:13px;
226
+ opacity:0; pointer-events:none; transition:opacity .2s ease-out, transform .2s ease-out;
227
+ }
228
+ .toast.show { opacity:1; transform:translate(-50%, 0); }
229
+ .toast.err { border-color:var(--red); color:oklch(0.85 0.06 26); }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <header>
234
+ <h1><svg viewBox="0 0 64 64" width="18" height="18" style="vertical-align:-3px; margin-right:2px" aria-hidden="true"><rect x="16" y="44" width="34" height="10" rx="5" fill="#57534b"/><rect x="11" y="29" width="34" height="10" rx="5" fill="#8a8375"/><rect x="6" y="14" width="34" height="10" rx="5" fill="#dfa662"/><circle cx="48" cy="19" r="4.5" fill="#5ec488"/><circle cx="48" cy="19" r="7.5" fill="#5ec488" opacity="0.25"/></svg> stack<span>deck</span></h1>
235
+ <span id="summary"></span>
236
+ <span class="spacer"></span>
237
+ <button class="ghost" id="hiddenToggle" onclick="toggleHidden()" title="show/hide the items you've hidden"></button>
238
+ <button class="ghost" onclick="toggleSettings()">settings</button>
239
+ <span class="paths" id="metaPaths"></span>
240
+ </header>
241
+ <main>
242
+ <div class="ledger" id="settings" style="display:none; margin-top:18px">
243
+ <div class="drawer open" style="padding:16px 18px">
244
+ <div class="cfg-label">Scanned folders <span class="count">tick a subfolder to include it, untick to exclude</span></div>
245
+ <div id="cfg-roots-list"></div>
246
+ <div id="cfg-browser" style="display:none"></div>
247
+ <button class="ghost" onclick="browseFolders()">+ add folder…</button>
248
+ <div class="cfg-label" style="margin-top:16px">Project categories <span class="count">drag to change the order they appear on the board</span></div>
249
+ <div id="cfg-cats" class="cat-list"></div>
250
+ <div style="display:flex; gap:10px; align-items:center; margin-top:12px">
251
+ <button onclick="saveConfig()">Save</button>
252
+ <span class="cmdline" id="cfg-path"></span>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ <div class="section-label">Services <span class="count" id="svc-count"></span>
257
+ <span style="margin-left:auto; display:flex; gap:4px">
258
+ <button class="ghost" onclick="collapseAll('g', true)">collapse all</button>
259
+ <button class="ghost" onclick="collapseAll('g', false)">expand all</button>
260
+ <button class="ghost" onclick="addSection()">+ section</button>
261
+ </span>
262
+ </div>
263
+ <div id="services"></div>
264
+
265
+ <div class="section-label">Dev tools <span class="count" id="infra-count"></span></div>
266
+ <div class="ledger" id="infra"></div>
267
+
268
+ <div class="section-label">
269
+ Projects <span class="count" id="proj-count"></span>
270
+ <span style="margin-left:auto; display:flex; gap:4px; align-items:center">
271
+ <button class="ghost" onclick="addCategory()">+ category</button>
272
+ <button class="ghost" onclick="refreshProjects()" title="rescan project roots for new folders">↻ refresh</button>
273
+ <button class="ghost" onclick="collapseAll('c', true)">collapse all</button>
274
+ <button class="ghost" onclick="collapseAll('c', false)">expand all</button>
275
+ <input id="filter" type="search" placeholder="filter projects…" oninput="renderProjects()">
276
+ </span>
277
+ </div>
278
+ <div id="projects"></div>
279
+ </main>
280
+ <div class="toast" id="toast"></div>
281
+
282
+ <script>
283
+ const TOKEN = "__STACKDECK_TOKEN__"; // injected by the daemon when serving this page
284
+ const $svc = document.getElementById("services");
285
+ const $proj = document.getElementById("projects");
286
+ const $toast = document.getElementById("toast");
287
+ const open = new Set(), logOpen = new Set(), streams = Object.create(null);
288
+ let services = [], projects = [], groups = [], instancesList = [];
289
+ let hiddenGroups = [], hiddenCategories = [], showHidden = false;
290
+
291
+ /* collapse state: keys "g:<group>" (services) and "c:<category>" (projects) */
292
+ const collapsed = new Set(JSON.parse(localStorage.getItem("stackdeck.collapsed") || "[]"));
293
+ function saveCollapsed() { localStorage.setItem("stackdeck.collapsed", JSON.stringify([...collapsed])); }
294
+ function toggleCollapse(key) {
295
+ collapsed.has(key) ? collapsed.delete(key) : collapsed.add(key);
296
+ saveCollapsed();
297
+ key.startsWith("g:") ? renderServices() : renderProjects();
298
+ }
299
+ function collapseAll(prefix, on) {
300
+ const keys = prefix === "g"
301
+ ? (groups.length ? [...groups, ""] : [""]).map(g => "g:" + g)
302
+ : [...new Set(projects.map(p => p.category || "Uncategorized"))].map(c => "c:" + c);
303
+ for (const k of keys) on ? collapsed.add(k) : collapsed.delete(k);
304
+ saveCollapsed();
305
+ prefix === "g" ? renderServices() : renderProjects();
306
+ }
307
+ function chevTitle(key, label, count) {
308
+ return `<span class="title" onclick="toggleCollapse(decodeURIComponent('${encodeURIComponent(key)}'))">
309
+ <span class="chev ${collapsed.has(key) ? "closed" : ""}">▾</span>${label}
310
+ <span class="count">${count}</span></span>`;
311
+ }
312
+
313
+ function toggleHidden() { showHidden = !showHidden; renderServices(); renderProjects(); updateHiddenToggle(); }
314
+ function updateHiddenToggle() {
315
+ const n = services.filter(s => s.hidden).length + hiddenGroups.length + hiddenCategories.length;
316
+ const el = document.getElementById("hiddenToggle");
317
+ el.textContent = showHidden ? "hide hidden" : `hidden (${n})`;
318
+ el.style.display = n ? "" : "none";
319
+ }
320
+ async function hideItem(type, name, hidden) {
321
+ try { await api("/api/hide", { type, name, hidden }); }
322
+ catch (e) { toast(e.message, true); }
323
+ loadServices(true); renderProjects();
324
+ }
325
+
326
+ async function startAll(group) {
327
+ toast("starting section (dependency order)…");
328
+ try {
329
+ const { results } = await api("/api/start-all", { group });
330
+ const started = Object.values(results).filter(v => v.started).length;
331
+ const failed = Object.entries(results).filter(([, v]) => v.error);
332
+ toast(failed.length
333
+ ? `started ${started}; failed: ${failed.map(([n, v]) => `${n} (${v.error})`).join("; ")}`
334
+ : `started ${started}${Object.values(results).some(v => v.alreadyRunning) ? " (rest already up)" : ""}`,
335
+ failed.length > 0);
336
+ } catch (e) { toast(e.message, true); }
337
+ loadServices(true);
338
+ }
339
+ async function stopAll(group) {
340
+ const targets = services.filter(s => (s.group || null) === group && s.running);
341
+ if (!targets.length) return toast("nothing to stop");
342
+ const r = await Promise.allSettled(targets.map(s => api("/api/stop", { name: s.name })));
343
+ const ok = r.filter(x => x.status === "fulfilled").length;
344
+ const notes = r.filter(x => x.status === "fulfilled" && x.value.note).map(x => x.value.note);
345
+ const errs = r.filter(x => x.status === "rejected").map(x => x.reason.message);
346
+ toast([`stopped ${ok}/${targets.length}`, ...notes, ...errs].join(" · "), notes.length + errs.length > 0);
347
+ setTimeout(() => loadServices(true), 500);
348
+ }
349
+
350
+ function toast(msg, err) {
351
+ $toast.textContent = msg;
352
+ $toast.className = "toast show" + (err ? " err" : "");
353
+ clearTimeout(toast.t);
354
+ toast.t = setTimeout(() => ($toast.className = "toast"), 4200);
355
+ }
356
+
357
+ async function api(path, body) {
358
+ const headers = { "X-Stackdeck-Token": TOKEN };
359
+ if (body) headers["Content-Type"] = "application/json";
360
+ const r = await fetch(path, body ? { method:"POST", headers, body: JSON.stringify(body) } : { headers });
361
+ const j = await r.json().catch(() => ({}));
362
+ if (!r.ok) throw new Error(j.error || r.statusText);
363
+ return j;
364
+ }
365
+ // HTML/attribute escaping. NOTE: this is NOT protection inside inline
366
+ // onclick handlers (the HTML parser decodes entities before JS parses) —
367
+ // there, safety comes from server-side name validation ([A-Za-z0-9._-] only)
368
+ // or the encodeURIComponent/decodeURIComponent pattern used for free text.
369
+ function esc(s){ return String(s??"").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
370
+ // Shorten a path with the real home dir from /api/meta (never a guessed prefix).
371
+ function shortP(p){ return meta && meta.home && p.startsWith(meta.home) ? "~" + p.slice(meta.home.length) : p; }
372
+ const shortDir = shortP;
373
+
374
+ /* ---------- services ---------- */
375
+
376
+ function svcRow(s) {
377
+ // Dirty (uncommitted changes) shows as an amber outline on the dropdown —
378
+ // a badge would shift the columns.
379
+ const selTitle = s.running
380
+ ? `on ${s.branch} — Kill first to switch the main checkout, or ⧉ to run the selected branch in a parallel worktree${s.dirty ? " · uncommitted changes" : ""}`
381
+ : (s.dirty ? "uncommitted changes — commit or stash to switch branch" : "branch to run (⧉ runs it in a parallel worktree)");
382
+ const branch = `<span class="branch-slot">${s.isGit
383
+ ? `<select id="br-${s.name}" class="${s.dirty ? "dirty" : ""}" title="${esc(selTitle)}">
384
+ ${s.branches.map(b => `<option ${b===s.branch?"selected":""}>${esc(b)}</option>`).join("")}
385
+ </select>
386
+ <button class="ghost" style="padding:3px 7px" title="run the selected branch in a parallel worktree (own checkout, auto $PORT)"
387
+ onclick="worktreeStart('${esc(s.name)}')">⧉</button>`
388
+ : '<span class="badge plain">no git</span>'}</span>`;
389
+ return `
390
+ <div class="row ${s.hidden ? "hiddenItem" : ""}" data-name="${esc(s.name)}"
391
+ ondragover="rowOver(event)" ondragleave="rowLeave(event)" ondrop="rowDrop(event)">
392
+ <div class="row-main">
393
+ <span class="handle" draggable="true" title="drag to reorder or move to a section"
394
+ ondragstart="dragStart(event,'${esc(s.name)}')" ondragend="dragEnd()">⠿</span>
395
+ <span class="dot ${s.running ? "up" : ""}"></span>
396
+ <span class="svc-name">${esc(s.name)}</span>
397
+ ${s.resurrected
398
+ ? '<span class="badge crash" title="Stackdeck killed it, but a supervisor (brew services / launchd / systemd) restarted it — stop it with its own manager, e.g. brew services stop">auto-restarted</span>'
399
+ : s.running && !s.managed ? '<span class="badge ext" title="running, but not started from Stackdeck">external</span>' : ""}
400
+ ${s.restartPending ? '<span class="badge dirty" title="crashed; auto-restart scheduled">restarting…</span>' : ""}
401
+ ${!s.running && !s.restartPending && s.lastExit && !s.lastExit.expected && s.lastExit.code !== 0
402
+ ? `<span class="badge crash" title="exited with code ${s.lastExit.code}">crashed</span>` : ""}
403
+ ${meta && meta.proxyPort && s.port
404
+ ? `<a class="badge link" target="_blank" href="http://${esc(s.name)}.localhost${meta.proxyPort === 80 ? "" : ":" + meta.proxyPort}"
405
+ title="via the *.localhost proxy">${esc(s.name)}.localhost</a>` : ""}
406
+ <span class="addr">:${s.port ?? "—"}${s.pid ? " · " + s.pid : ""}</span>
407
+ <span class="grow cmdline" title="${esc(s.dir)}">${esc(shortDir(s.dir))} · <b>${esc(s.command)}</b></span>
408
+ ${branch}
409
+ <span class="row-actions">
410
+ ${s.running
411
+ ? `<button class="kill primary" onclick="stop('${s.name}')">Kill</button>`
412
+ : `<button class="start primary" onclick="start('${s.name}')">Start</button>`}
413
+ <button class="ghost" ${s.running ? "" : "disabled"} title="stop, then start again on the same branch"
414
+ onclick="restart('${s.name}')">restart</button>
415
+ <button class="ghost" onclick="toggleLogs('${s.name}')">logs</button>
416
+ <button class="ghost" onclick="toggleEdit('${s.name}')">edit</button>
417
+ <button class="ghost" title="${s.hidden ? "show on board" : "hide from board"}"
418
+ onclick="hideItem('service','${esc(s.name)}',${!s.hidden})">${s.hidden ? "unhide" : "hide"}</button>
419
+ </span>
420
+ </div>
421
+ <div class="drawer ${open.has(s.name) ? "open" : ""}" id="edit-${s.name}">
422
+ <div class="fields">
423
+ <label>name</label><input id="name-${s.name}" value="${esc(s.name)}">
424
+ <label>dir</label><input id="dir-${s.name}" value="${esc(s.dir)}">
425
+ <label>command</label><textarea id="cmd-${s.name}">${esc(s.command)}</textarea>
426
+ <label>port</label><input id="port-${s.name}" value="${s.port ?? ""}" placeholder="for status detection">
427
+ <label>env</label><textarea id="env-${s.name}" placeholder='{"KEY":"value"} — .env in dir loads automatically; these override it'>${esc(JSON.stringify(s.env || {}))}</textarea>
428
+ <label>restart</label>
429
+ <select id="rs-${s.name}" style="max-width:160px">
430
+ <option value="">never (default)</option>
431
+ <option value="on-failure" ${s.restart === "on-failure" ? "selected" : ""}>on failure (backoff, 5 tries)</option>
432
+ </select>
433
+ <label>depends on</label><input id="dep-${s.name}" value="${esc((s.dependsOn || []).join(", "))}" placeholder="comma-separated service names, started first">
434
+ <label>ready when</label><input id="rw-${s.name}" value="${esc((s.readyWhen && s.readyWhen.log) || "")}" placeholder="regex matched against its logs (default: port opens)">
435
+ <label></label>
436
+ <div style="display:flex; gap:8px">
437
+ <button onclick="saveSvc('${s.name}')">Save</button>
438
+ <button class="ghost" onclick="delSvc('${s.name}')">remove</button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ <div class="drawer ${logOpen.has(s.name) ? "open" : ""}" id="logwrap-${s.name}">
443
+ <input class="logfilter" id="lf-${s.name}" type="search" placeholder="filter logs…" oninput="filterLogs('${s.name}')">
444
+ <div class="logs" id="log-${s.name}"></div>
445
+ </div>
446
+ </div>`;
447
+ }
448
+
449
+
450
+ function renderServices() {
451
+ const up = services.filter(s => s.running).length;
452
+ document.getElementById("svc-count").textContent = `${up} running · ${services.length - up} stopped`;
453
+ document.getElementById("summary").textContent = up ? `${up} service${up>1?"s":""} up` : "all quiet";
454
+ if (!services.length) {
455
+ $svc.innerHTML = '<div class="ledger"><div class="empty">No services yet. Configure one from the Projects list below.</div></div>';
456
+ return;
457
+ }
458
+ const sections = groups.length ? [...groups, null] : [null];
459
+ $svc.innerHTML = sections.map(g => {
460
+ const gHidden = g !== null && hiddenGroups.includes(g);
461
+ if (gHidden && !showHidden) return "";
462
+ const rows = services.filter(s => (s.group || null) === g && (showHidden || !s.hidden));
463
+ if (g === null && groups.length && !rows.length) return "";
464
+ const gArg = g === null ? "null" : `decodeURIComponent('${encodeURIComponent(g)}')`;
465
+ const key = "g:" + (g ?? "");
466
+ const isClosed = collapsed.has(key);
467
+ const running = rows.filter(r => r.running).length;
468
+ const bulk = `<span class="actions">
469
+ <button class="ghost" onclick="startAll(${gArg})" title="start every stopped service here">start all</button>
470
+ <button class="ghost" onclick="stopAll(${gArg})" title="stop every running service here">stop all</button>`;
471
+ const head = g !== null
472
+ ? `<div class="group-head">${chevTitle(key, esc(g), `${running}/${rows.length} up`)}${bulk}
473
+ <button class="ghost" onclick="renameSection(${gArg})">rename</button>
474
+ <button class="ghost" onclick="hideItem('group',${gArg},${!gHidden})">${gHidden ? "unhide" : "hide"}</button>
475
+ <button class="ghost" aria-label="delete section" title="delete section (services move to Ungrouped)" onclick="delSection(${gArg})">×</button></span></div>`
476
+ : (groups.length ? `<div class="group-head">${chevTitle(key, "Ungrouped", `${running}/${rows.length} up`)}${bulk}</span></div>` : "");
477
+ const rowHtml = (s) => svcRow(s) + instancesList.filter(i => i.svc === s.name).map(instanceRow).join("");
478
+ const ledger = isClosed && head ? "" : `
479
+ <div class="ledger" data-group="${g === null ? "" : esc(g)}"
480
+ ondragover="ledgerOver(event)" ondragleave="ledgerLeave(event)" ondrop="ledgerDrop(event)">
481
+ ${rows.length ? rows.map(rowHtml).join("") : '<div class="empty">drop services here</div>'}
482
+ </div>`;
483
+ return `<div class="group-block ${gHidden ? "hiddenItem" : ""}">${head}${ledger}</div>`;
484
+ }).join("");
485
+ for (const n of logOpen) attachStream(n);
486
+ updateHiddenToggle();
487
+ }
488
+
489
+ /* ---------- drag & drop ---------- */
490
+
491
+ let dragging = null;
492
+
493
+ function dragStart(ev, name) {
494
+ dragging = name;
495
+ ev.dataTransfer.effectAllowed = "move";
496
+ ev.dataTransfer.setData("text/plain", name);
497
+ const row = ev.target.closest(".row");
498
+ ev.dataTransfer.setDragImage(row, 20, 20);
499
+ setTimeout(() => row.classList.add("dragging"), 0);
500
+ }
501
+ function dragEnd() {
502
+ dragging = null;
503
+ document.querySelectorAll(".dragging,.drop-before,.drop-into")
504
+ .forEach(el => el.classList.remove("dragging","drop-before","drop-into"));
505
+ }
506
+ function rowOver(ev) {
507
+ if (!dragging) return;
508
+ ev.preventDefault(); ev.stopPropagation();
509
+ ev.dataTransfer.dropEffect = "move";
510
+ const row = ev.currentTarget;
511
+ if (row.dataset.name !== dragging) row.classList.add("drop-before");
512
+ }
513
+ function rowLeave(ev) { ev.currentTarget.classList.remove("drop-before"); }
514
+ async function rowDrop(ev) {
515
+ ev.preventDefault(); ev.stopPropagation();
516
+ const row = ev.currentTarget;
517
+ row.classList.remove("drop-before");
518
+ const before = row.dataset.name;
519
+ const group = row.closest(".ledger").dataset.group || null;
520
+ if (!dragging || before === dragging) return dragEnd();
521
+ await move(dragging, group, before);
522
+ }
523
+ function ledgerOver(ev) {
524
+ if (!dragging) return;
525
+ ev.preventDefault();
526
+ ev.dataTransfer.dropEffect = "move";
527
+ ev.currentTarget.classList.add("drop-into");
528
+ }
529
+ function ledgerLeave(ev) { ev.currentTarget.classList.remove("drop-into"); }
530
+ async function ledgerDrop(ev) {
531
+ ev.preventDefault();
532
+ ev.currentTarget.classList.remove("drop-into");
533
+ const group = ev.currentTarget.dataset.group || null;
534
+ if (!dragging) return;
535
+ await move(dragging, group, null); // append at end of section
536
+ }
537
+ async function move(name, group, before) {
538
+ const n = name; dragEnd();
539
+ try { await api("/api/move", { name: n, group, before }); }
540
+ catch (e) { toast(e.message, true); }
541
+ loadServices(true);
542
+ }
543
+
544
+ /* ---------- sections ---------- */
545
+
546
+ async function addSection() {
547
+ const name = prompt("Section name (e.g. Frontend, Backend, Infra):");
548
+ if (!name || !name.trim()) return;
549
+ try { await api("/api/group", { name: name.trim() }); loadServices(true); }
550
+ catch (e) { toast(e.message, true); }
551
+ }
552
+ async function renameSection(name) {
553
+ const newName = prompt(`Rename section '${name}' to:`, name);
554
+ if (!newName || !newName.trim() || newName.trim() === name) return;
555
+ try {
556
+ await api("/api/group/rename", { name, newName: newName.trim() });
557
+ if (collapsed.has("g:" + name)) { collapsed.delete("g:" + name); collapsed.add("g:" + newName.trim()); saveCollapsed(); }
558
+ loadServices(true);
559
+ } catch (e) { toast(e.message, true); }
560
+ }
561
+ async function delSection(name) {
562
+ if (!confirm(`Delete section '${name}'? Its services move to Ungrouped.`)) return;
563
+ try { await api("/api/group/delete", { name }); loadServices(true); }
564
+ catch (e) { toast(e.message, true); }
565
+ }
566
+
567
+ /* ---------- projects ---------- */
568
+
569
+ function projRow(p) {
570
+ const nArg = `decodeURIComponent('${encodeURIComponent(p.name)}')`;
571
+ const branch = p.isGit
572
+ ? `<span class="badge git" title="current branch">${esc(p.branch || "?")}</span>`
573
+ : '<span class="badge plain">no git</span>';
574
+ const cmd = p.procs && p.procs.length
575
+ ? `<span class="grow cmdline"><b>${p.procs.length} processes</b> · ${esc(p.procs.map(x => x.name).join(", ")).slice(0, 80)}</span>`
576
+ : p.suggestedCommand
577
+ ? `<span class="grow cmdline"><b>${esc(p.suggestedCommand)}</b></span>`
578
+ : '<span class="grow cmdline">no run command detected</span>';
579
+ return `
580
+ <div class="row">
581
+ <div class="row-main">
582
+ <span class="handle" draggable="true" title="drag into one of your categories"
583
+ ondragstart="projDragStart(event, ${nArg})" ondragend="projDragEnd()">⠿</span>
584
+ <span class="svc-name" style="min-width:200px">${esc(p.name)}</span>
585
+ ${branch}
586
+ ${cmd}
587
+ ${p.configured
588
+ ? '<span class="badge cfgd">configured</span>'
589
+ : `<button class="add" onclick="configure(${nArg})">configure</button>`}
590
+ </div>
591
+ </div>`;
592
+ }
593
+
594
+ /* project drag → category assignment */
595
+ let dragProj = null;
596
+ function projDragStart(ev, name) {
597
+ dragProj = name;
598
+ ev.dataTransfer.effectAllowed = "move";
599
+ ev.dataTransfer.setData("text/plain", name);
600
+ const row = ev.target.closest(".row");
601
+ ev.dataTransfer.setDragImage(row, 20, 20);
602
+ setTimeout(() => row.classList.add("dragging"), 0);
603
+ }
604
+ function projDragEnd() {
605
+ dragProj = null;
606
+ document.querySelectorAll("#projects .dragging,#projects .drop-into")
607
+ .forEach(el => el.classList.remove("dragging","drop-into"));
608
+ }
609
+ function catOver(ev) {
610
+ if (!dragProj) return;
611
+ ev.preventDefault();
612
+ ev.dataTransfer.dropEffect = "move";
613
+ ev.currentTarget.classList.add("drop-into");
614
+ }
615
+ function catLeave(ev) { ev.currentTarget.classList.remove("drop-into"); }
616
+ async function catDrop(ev) {
617
+ ev.preventDefault();
618
+ ev.currentTarget.classList.remove("drop-into");
619
+ const cat = ev.currentTarget.dataset.cat || null; // "" = Uncategorized
620
+ const name = dragProj;
621
+ projDragEnd();
622
+ if (!name) return;
623
+ try {
624
+ await api("/api/project/category", { name, category: cat });
625
+ toast(`${name} → ${cat || "Uncategorized"}`);
626
+ } catch (e) { toast(e.message, true); }
627
+ await loadMeta();
628
+ loadProjects(true);
629
+ }
630
+
631
+ async function addCategory() {
632
+ const name = prompt("Category name (e.g. Active, Client work, Archive):");
633
+ if (!name || !name.trim()) return;
634
+ try { await api("/api/category", { name: name.trim() }); await loadMeta(); loadProjects(true); }
635
+ catch (e) { toast(e.message, true); }
636
+ }
637
+ async function delCategory(name) {
638
+ if (!confirm(`Delete category '${name}'? Its projects move to Uncategorized.`)) return;
639
+ try { await api("/api/category/delete", { name }); await loadMeta(); loadProjects(true); }
640
+ catch (e) { toast(e.message, true); }
641
+ }
642
+
643
+ let CAT_ORDER = ["Uncategorized"]; // overridden by config.categoryOrder via /api/meta
644
+
645
+ function renderProjects() {
646
+ const q = document.getElementById("filter").value.toLowerCase();
647
+ const shown = projects.filter(p => !q || p.name.toLowerCase().includes(q) ||
648
+ (p.category || "").toLowerCase().includes(q));
649
+ document.getElementById("proj-count").textContent =
650
+ `${projects.length} projects · ${projects.filter(p=>p.isGit).length} git`;
651
+ if (q && !shown.length) { $proj.innerHTML = '<div class="ledger"><div class="empty">Nothing matches.</div></div>'; return; }
652
+ // Your categories (in your order) — shown even when empty, so you can drag
653
+ // into a fresh one — then any others found in data, then Uncategorized.
654
+ const fromData = [...new Set(shown.map(p => p.category || "Uncategorized"))];
655
+ const cats = [
656
+ ...CAT_ORDER.filter(c => c !== "Uncategorized"),
657
+ ...fromData.filter(c => c !== "Uncategorized" && !CAT_ORDER.includes(c)).sort(),
658
+ "Uncategorized",
659
+ ];
660
+ $proj.innerHTML = cats.map(c => {
661
+ const cHidden = hiddenCategories.includes(c);
662
+ if (cHidden && !showHidden) return "";
663
+ const rows = shown.filter(p => (p.category || "Uncategorized") === c);
664
+ if (q && !rows.length) return ""; // filtering: only categories with matches
665
+ const cArg = `decodeURIComponent('${encodeURIComponent(c)}')`;
666
+ const key = "c:" + c;
667
+ const isClosed = collapsed.has(key) && !q; // an active filter always shows its matches
668
+ const isUncat = c === "Uncategorized";
669
+ return `<div class="group-block ${cHidden ? "hiddenItem" : ""}">
670
+ <div class="group-head">${chevTitle(key, esc(c), rows.length)}
671
+ <span class="actions">
672
+ ${isUncat ? "" : `<button class="ghost" onclick="hideItem('category',${cArg},${!cHidden})">${cHidden ? "unhide" : "hide"}</button>
673
+ <button class="ghost" aria-label="delete category" title="delete category (projects move to Uncategorized)" onclick="delCategory(${cArg})">×</button>`}
674
+ </span>
675
+ </div>
676
+ ${isClosed ? "" : `<div class="ledger" data-cat="${isUncat ? "" : esc(c)}"
677
+ ondragover="catOver(event)" ondragleave="catLeave(event)" ondrop="catDrop(event)">
678
+ ${rows.length ? rows.map(projRow).join("") : '<div class="empty">drag projects here</div>'}
679
+ </div>`}
680
+ </div>`;
681
+ }).join("");
682
+ updateHiddenToggle();
683
+ }
684
+
685
+ async function configure(name) {
686
+ const p = projects.find(x => x.name === name);
687
+ if (!p) return;
688
+ const base = name.split("/").pop().toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
689
+ try {
690
+ // Multi-process repo (Procfile / compose services / pnpm workspace):
691
+ // one service per process, grouped into a section named after the repo.
692
+ if (p.procs && p.procs.length) {
693
+ const section = name.split("/").pop().slice(0, 64);
694
+ await api("/api/group", { name: section }).catch(() => {}); // may already exist
695
+ let created = 0;
696
+ for (const proc of p.procs) {
697
+ const svcName = `${base}-${proc.name}`.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 64);
698
+ if (services.some(s => s.name === svcName)) continue;
699
+ await api("/api/service", { name: svcName, dir: proc.dir, command: proc.command, group: section });
700
+ created++;
701
+ }
702
+ toast(`created ${created} services in section '${section}'`);
703
+ await load();
704
+ return;
705
+ }
706
+ let svcName = base;
707
+ if (services.some(s => s.name === svcName)) svcName += "-2";
708
+ await api("/api/service", { name: svcName, dir: p.dir, command: p.suggestedCommand || "echo set a command" });
709
+ toast(`${svcName} added to Services${p.suggestedCommand ? "" : " — set its command"}`);
710
+ open.add(svcName);
711
+ await load();
712
+ document.getElementById("edit-" + svcName)?.scrollIntoView({ block:"center", behavior:"smooth" });
713
+ } catch (e) { toast(e.message, true); }
714
+ }
715
+
716
+ /* ---------- actions ---------- */
717
+
718
+ function instanceRow(i) {
719
+ return `
720
+ <div class="row">
721
+ <div class="row-main" style="padding-left:38px">
722
+ <span class="dot up"></span>
723
+ <span class="svc-name" style="font-weight:500">⧉ ${esc(i.svc)} <span class="cmdline">@ ${esc(i.branch)}</span></span>
724
+ <span class="badge dirty" title="parallel worktree instance">worktree</span>
725
+ ${meta && meta.proxyPort && i.port
726
+ ? `<a class="badge link" target="_blank" href="http://${esc(i.key.split("@")[1])}.${esc(i.svc)}.localhost${meta.proxyPort === 80 ? "" : ":" + meta.proxyPort}"
727
+ title="via the *.localhost proxy">${esc(i.key.split("@")[1])}.${esc(i.svc)}.localhost</a>` : ""}
728
+ <span class="addr">:${i.port ?? "—"} · ${i.pid}</span>
729
+ <span class="grow"></span>
730
+ <span class="row-actions">
731
+ <button class="kill primary" onclick="stopInstance('${esc(i.key)}')">Kill</button>
732
+ <button class="ghost" onclick="toggleLogs('${esc(i.key)}')">logs</button>
733
+ </span>
734
+ </div>
735
+ <div class="drawer ${logOpen.has(i.key) ? "open" : ""}" id="logwrap-${i.key}">
736
+ <input class="logfilter" id="lf-${i.key}" type="search" placeholder="filter logs…" oninput="filterLogs('${esc(i.key)}')">
737
+ <div class="logs" id="log-${i.key}"></div>
738
+ </div>
739
+ </div>`;
740
+ }
741
+ async function worktreeStart(name) {
742
+ const sel = document.getElementById("br-" + name);
743
+ if (!sel) return;
744
+ const s = services.find(x => x.name === name);
745
+ if (s && sel.value === s.branch)
746
+ return toast(`'${sel.value}' is checked out in the main copy — pick a different branch for a worktree`, true);
747
+ try {
748
+ const r = await api("/api/worktree/start", { name, branch: sel.value });
749
+ toast(`${r.key} running${r.port ? ` with PORT=${r.port}` : ""}`);
750
+ logOpen.add(r.key);
751
+ } catch (e) { toast(e.message, true); }
752
+ loadServices(true);
753
+ }
754
+ async function stopInstance(key) {
755
+ try { await api("/api/worktree/stop", { key }); toast(key + " killed"); }
756
+ catch (e) { toast(e.message, true); }
757
+ setTimeout(() => loadServices(true), 400);
758
+ }
759
+
760
+ async function start(name, force) {
761
+ const sel = document.getElementById("br-" + name);
762
+ try {
763
+ const s = services.find(x => x.name === name);
764
+ // when stopped, the dropdown choice is a checkout request; when it was
765
+ // running the select stays enabled for worktrees only
766
+ await api("/api/start", { name, branch: sel && s && !s.running ? sel.value : undefined, force: !!force });
767
+ toast(name + " started");
768
+ logOpen.add(name);
769
+ } catch (e) {
770
+ const busy = e.message.match(/port (\d+) is busy \(pid (\d+)/);
771
+ if (busy && !force && confirm(`Port ${busy[1]} is used by another process (pid ${busy[2]}).\nKill it and start ${name}?`))
772
+ return start(name, true);
773
+ toast(e.message, true);
774
+ }
775
+ loadServices(true);
776
+ }
777
+ async function stop(name) {
778
+ try {
779
+ const r = await api("/api/stop", { name });
780
+ toast(r.note || name + " killed", !!r.note);
781
+ }
782
+ catch (e) { toast(e.message, true); }
783
+ setTimeout(loadServices, 400);
784
+ }
785
+ async function restart(name) {
786
+ toast(name + " restarting…");
787
+ try { await api("/api/restart", { name }); toast(name + " restarted"); logOpen.add(name); }
788
+ catch (e) { toast(e.message, true); }
789
+ loadServices(true);
790
+ }
791
+ function toggleEdit(name) {
792
+ open.has(name) ? open.delete(name) : open.add(name);
793
+ document.getElementById("edit-" + name).classList.toggle("open", open.has(name));
794
+ }
795
+ function toggleLogs(name) {
796
+ if (logOpen.has(name)) { logOpen.delete(name); streams[name]?.close(); delete streams[name]; }
797
+ else logOpen.add(name);
798
+ document.getElementById("logwrap-" + name).classList.toggle("open", logOpen.has(name));
799
+ if (logOpen.has(name)) attachStream(name);
800
+ }
801
+ /* Minimal ANSI SGR → HTML (colors 30–37/90–97, bold, dim, reset). */
802
+ const ANSI_FG = { 30:"#57534c",31:"#e06c5f",32:"#84c17b",33:"#d9a05b",34:"#7aa2d4",35:"#c092d0",36:"#72bcb8",37:"#b8b2a7",
803
+ 90:"#6e6a63",91:"#f08d80",92:"#a0d598",93:"#e8bc7e",94:"#9dbfe6",95:"#d5b0e2",96:"#93d5d1",97:"#d6d0c4" };
804
+ function ansiToHtml(raw) {
805
+ let out = "", openSpan = false;
806
+ for (const part of raw.split(/(\x1b\[[0-9;]*m)/)) {
807
+ const m = part.match(/^\x1b\[([0-9;]*)m$/);
808
+ if (!m) { out += esc(part); continue; }
809
+ if (openSpan) { out += "</span>"; openSpan = false; }
810
+ const codes = m[1].split(";").filter(Boolean).map(Number);
811
+ const styles = [];
812
+ for (const c of codes) {
813
+ if (ANSI_FG[c]) styles.push("color:" + ANSI_FG[c]);
814
+ else if (c === 1) styles.push("font-weight:600");
815
+ else if (c === 2) styles.push("opacity:.6");
816
+ }
817
+ if (styles.length) { out += `<span style="${styles.join(";")}">`; openSpan = true; }
818
+ }
819
+ return out + (openSpan ? "</span>" : "");
820
+ }
821
+
822
+ const clientLogs = Object.create(null); // name -> [raw lines], powers the per-pane filter
823
+ function logMatches(name, line) {
824
+ const q = (document.getElementById("lf-" + name)?.value || "").toLowerCase();
825
+ return !q || line.toLowerCase().includes(q);
826
+ }
827
+ function filterLogs(name) {
828
+ const pane = document.getElementById("log-" + name);
829
+ if (!pane) return;
830
+ pane.innerHTML = (clientLogs[name] || [])
831
+ .filter(l => logMatches(name, l))
832
+ .map(l => ansiToHtml(l) + "\n").join("");
833
+ pane.scrollTop = pane.scrollHeight;
834
+ }
835
+ function attachStream(name) {
836
+ streams[name]?.close();
837
+ const pane = document.getElementById("log-" + name);
838
+ if (!pane) return;
839
+ pane.innerHTML = "";
840
+ clientLogs[name] = [];
841
+ const es = new EventSource("/api/logs?name=" + encodeURIComponent(name) + "&t=" + TOKEN);
842
+ es.onmessage = (ev) => {
843
+ const line = JSON.parse(ev.data);
844
+ const buf = clientLogs[name];
845
+ buf.push(line);
846
+ if (buf.length > 3000) buf.shift();
847
+ if (!logMatches(name, line)) return;
848
+ pane.insertAdjacentHTML("beforeend", ansiToHtml(line) + "\n");
849
+ while (pane.childNodes.length > 6000) pane.removeChild(pane.firstChild);
850
+ pane.scrollTop = pane.scrollHeight;
851
+ };
852
+ streams[name] = es;
853
+ }
854
+ async function saveSvc(name) {
855
+ let env;
856
+ try { env = JSON.parse(document.getElementById("env-" + name).value || "{}"); }
857
+ catch { return toast("env must be valid JSON", true); }
858
+ try {
859
+ let finalName = name;
860
+ const wanted = document.getElementById("name-" + name).value.trim();
861
+ if (wanted && wanted !== name) {
862
+ await api("/api/service/rename", { name, newName: wanted });
863
+ if (logOpen.has(name)) { logOpen.delete(name); logOpen.add(wanted); streams[name]?.close(); delete streams[name]; }
864
+ finalName = wanted;
865
+ }
866
+ const cur = services.find(x => x.name === name) || {};
867
+ const rw = document.getElementById("rw-" + name).value.trim();
868
+ const body = {
869
+ name: finalName,
870
+ dir: document.getElementById("dir-" + name).value.trim(),
871
+ command: document.getElementById("cmd-" + name).value.trim(),
872
+ port: document.getElementById("port-" + name).value.trim() || undefined,
873
+ env,
874
+ restart: document.getElementById("rs-" + name).value || null,
875
+ dependsOn: document.getElementById("dep-" + name).value.split(",").map(x => x.trim()).filter(Boolean),
876
+ readyWhen: rw ? { log: rw } : null,
877
+ };
878
+ // Rename works mid-run; the other fields need a stopped service. Skip the
879
+ // field save when nothing but the name changed, so renaming while running
880
+ // doesn't half-fail.
881
+ const fieldsChanged = body.dir !== cur.dir || body.command !== cur.command ||
882
+ String(body.port ?? "") !== String(cur.port ?? "") || JSON.stringify(env) !== JSON.stringify(cur.env || {}) ||
883
+ (body.restart || "") !== (cur.restart || "") ||
884
+ JSON.stringify(body.dependsOn) !== JSON.stringify(cur.dependsOn || []) ||
885
+ rw !== ((cur.readyWhen && cur.readyWhen.log) || "");
886
+ if (fieldsChanged) await api("/api/service", body);
887
+ toast(finalName + " saved"); open.delete(name); load();
888
+ } catch (e) { toast(e.message, true); }
889
+ }
890
+ async function delSvc(name) {
891
+ if (!confirm("Remove '" + name + "' from Stackdeck? (the project itself is untouched)")) return;
892
+ try { await api("/api/service/delete", { name }); toast(name + " removed"); load(); }
893
+ catch (e) { toast(e.message, true); }
894
+ }
895
+
896
+ /* ---------- data loading ---------- */
897
+
898
+ function editGuard() {
899
+ if (open.size || dragging || dragProj) return true;
900
+ const ae = document.activeElement;
901
+ return ae && (ae.closest("#services") || ae.closest("#projects") || ae.id === "filter") &&
902
+ ["SELECT","INPUT","TEXTAREA"].includes(ae.tagName);
903
+ }
904
+ let prevUp = null; // name -> was running (crash detection across polls)
905
+ function notifyCrash(name, code) {
906
+ toast(`${name} crashed (exit ${code})`, true);
907
+ if (!("Notification" in window)) return;
908
+ if (Notification.permission === "default") Notification.requestPermission();
909
+ if (Notification.permission === "granted")
910
+ new Notification("Stackdeck", { body: `${name} crashed (exit code ${code})` });
911
+ }
912
+ async function loadServices(force) {
913
+ if (!force && editGuard()) return;
914
+ try {
915
+ const d = await api("/api/services");
916
+ groups = d.groups || []; services = d.services || [];
917
+ instancesList = d.instances || [];
918
+ hiddenGroups = d.hiddenGroups || []; hiddenCategories = d.hiddenCategories || [];
919
+ if (prevUp) for (const s of services) {
920
+ if (prevUp.get(s.name) && !s.running && s.lastExit && !s.lastExit.expected && s.lastExit.code !== 0)
921
+ notifyCrash(s.name, s.lastExit.code);
922
+ }
923
+ prevUp = new Map(services.map(s => [s.name, s.running]));
924
+ renderServices();
925
+ }
926
+ catch (e) { toast("Stackdeck server unreachable: " + e.message, true); }
927
+ }
928
+ /* ---------- dev tools (infra) ---------- */
929
+
930
+ let infra = [];
931
+ async function loadInfra() {
932
+ try { infra = await api("/api/infra"); renderInfra(); } catch {}
933
+ }
934
+ function renderInfra() {
935
+ document.getElementById("infra-count").textContent = `${infra.length} detected on this machine`;
936
+ document.getElementById("infra").innerHTML = infra.length ? infra.map(t => `
937
+ <div class="row">
938
+ <div class="row-main">
939
+ <span class="dot ${t.runningPid ? "up" : ""}"></span>
940
+ <span class="svc-name" style="min-width:140px">${esc(t.title)}</span>
941
+ <span class="addr">:${t.port}${t.runningPid ? " · " + t.runningPid : ""}</span>
942
+ <span class="grow cmdline"><b>${esc(t.command)}</b></span>
943
+ ${t.configured
944
+ ? '<span class="badge cfgd">configured</span>'
945
+ : `<button class="add" onclick="configureInfra('${esc(t.name)}')">configure</button>`}
946
+ </div>
947
+ </div>`).join("")
948
+ : '<div class="empty">No known databases or brokers found on PATH.</div>';
949
+ }
950
+ async function configureInfra(name) {
951
+ const t = infra.find(x => x.name === name);
952
+ if (!t) return;
953
+ try {
954
+ if (!groups.includes("Infra")) await api("/api/group", { name: "Infra" }).catch(() => {});
955
+ // envFile:false — a database must not inherit some random ~/.env
956
+ await api("/api/service", { name: t.name, dir: "~", command: t.command, port: t.port, envFile: false, group: "Infra" });
957
+ toast(`${t.title} added to Services (Infra section)`);
958
+ await load(); loadInfra();
959
+ } catch (e) { toast(e.message, true); }
960
+ }
961
+
962
+ async function loadProjects(fresh) {
963
+ try { projects = await api("/api/projects" + (fresh ? "?fresh" : "")); renderProjects(); } catch {}
964
+ }
965
+ async function refreshProjects() {
966
+ const before = projects.length;
967
+ await loadProjects(true);
968
+ const diff = projects.length - before;
969
+ toast(diff > 0 ? `rescanned — ${diff} new project${diff > 1 ? "s" : ""} found`
970
+ : diff < 0 ? `rescanned — ${-diff} project${diff < -1 ? "s" : ""} gone`
971
+ : "rescanned — no changes");
972
+ }
973
+
974
+ /* ---------- settings (VLC-style folder tree) ---------- */
975
+
976
+ let meta = null;
977
+ const pending = { roots: [], excludes: new Set(), openRoots: new Set() };
978
+
979
+ function toggleSettings() {
980
+ const el = document.getElementById("settings");
981
+ const show = el.style.display === "none";
982
+ el.style.display = show ? "" : "none";
983
+ if (show && meta) {
984
+ pending.roots = [...meta.projectRoots];
985
+ pending.excludes = new Set(meta.excludes || []);
986
+ pending.openRoots = new Set(pending.roots); // expanded by default, like VLC
987
+ // Ordered category list: saved order first, then any others alphabetically.
988
+ const existing = [...new Set(projects.map(p => p.category || "Uncategorized"))].filter(c => c !== "Uncategorized");
989
+ pending.cats = [
990
+ ...(meta.categoryOrder || []).filter(c => existing.includes(c)),
991
+ ...existing.filter(c => !(meta.categoryOrder || []).includes(c)).sort(),
992
+ ];
993
+ document.getElementById("cfg-path").textContent = "saved to " + shortP(meta.configPath);
994
+ document.getElementById("cfg-browser").style.display = "none";
995
+ renderRoots();
996
+ renderCatList();
997
+ }
998
+ }
999
+
1000
+ /* category reorder list */
1001
+ let dragCat = null;
1002
+ function renderCatList() {
1003
+ const counts = {};
1004
+ for (const p of projects) counts[p.category || "Uncategorized"] = (counts[p.category || "Uncategorized"] || 0) + 1;
1005
+ document.getElementById("cfg-cats").innerHTML = pending.cats.map((c, i) => `
1006
+ <div class="cat-row" draggable="true" data-i="${i}"
1007
+ ondragstart="dragCat=${i}; this.classList.add('dragging')"
1008
+ ondragend="dragCat=null; document.querySelectorAll('.cat-row').forEach(e=>e.classList.remove('dragging','drop-before'))"
1009
+ ondragover="event.preventDefault(); if(dragCat!==null && dragCat!=${i}) this.classList.add('drop-before')"
1010
+ ondragleave="this.classList.remove('drop-before')"
1011
+ ondrop="dropCat(event, ${i})">
1012
+ <span class="handle">⠿</span> ${esc(c)} <span class="count">${counts[c] || 0}</span>
1013
+ </div>`).join("") +
1014
+ `<div class="cat-row pinned" title="new and unmapped projects land here">Uncategorized — always last <span class="count">${counts["Uncategorized"] || 0}</span></div>`;
1015
+ }
1016
+ function dropCat(ev, to) {
1017
+ ev.preventDefault();
1018
+ if (dragCat === null || dragCat === to) return;
1019
+ const [moved] = pending.cats.splice(dragCat, 1);
1020
+ pending.cats.splice(to > dragCat ? to - 1 : to, 0, moved);
1021
+ dragCat = null;
1022
+ renderCatList();
1023
+ }
1024
+
1025
+ async function renderRoots() {
1026
+ const box = document.getElementById("cfg-roots-list");
1027
+ const blocks = await Promise.all(pending.roots.map(async (root) => {
1028
+ const rArg = `decodeURIComponent('${encodeURIComponent(root)}')`;
1029
+ const isOpen = pending.openRoots.has(root);
1030
+ let children = "";
1031
+ if (isOpen) {
1032
+ try {
1033
+ const fsList = await api("/api/fs?path=" + encodeURIComponent(root));
1034
+ children = `<div class="child-grid">` + fsList.dirs.map(d => {
1035
+ const full = (fsList.path.endsWith("/") ? fsList.path : fsList.path + "/") + d;
1036
+ const off = pending.excludes.has(full);
1037
+ const fArg = `decodeURIComponent('${encodeURIComponent(full)}')`;
1038
+ return `<label class="${off ? "off" : ""}">
1039
+ <input type="checkbox" ${off ? "" : "checked"} onchange="toggleExclude(${fArg}, this.checked)"> ${esc(d)}
1040
+ </label>`;
1041
+ }).join("") + `</div>`;
1042
+ } catch (e) { children = `<div class="child-grid"><span class="cmdline">${esc(e.message)}</span></div>`; }
1043
+ }
1044
+ return `<div class="root-row">
1045
+ <span class="chev ${isOpen ? "" : "closed"}" onclick="toggleRootOpen(${rArg})">▾</span>
1046
+ <b>${esc(shortP(root))}</b>
1047
+ <button class="ghost" onclick="removeRoot(${rArg})">remove</button>
1048
+ </div>${children}`;
1049
+ }));
1050
+ box.innerHTML = blocks.join("") ||
1051
+ '<div class="cmdline" style="padding:6px 0">no folders yet — add one below</div>';
1052
+ }
1053
+
1054
+ function toggleRootOpen(root) {
1055
+ pending.openRoots.has(root) ? pending.openRoots.delete(root) : pending.openRoots.add(root);
1056
+ renderRoots();
1057
+ }
1058
+ function toggleExclude(full, included) {
1059
+ included ? pending.excludes.delete(full) : pending.excludes.add(full);
1060
+ renderRoots();
1061
+ }
1062
+ function removeRoot(root) {
1063
+ pending.roots = pending.roots.filter(r => r !== root);
1064
+ renderRoots();
1065
+ }
1066
+
1067
+ /* folder browser for adding a root */
1068
+ let browsePath = null;
1069
+ async function browseFolders(p) {
1070
+ const box = document.getElementById("cfg-browser");
1071
+ box.style.display = "";
1072
+ try {
1073
+ const d = await api("/api/fs?path=" + encodeURIComponent(p || browsePath || "~"));
1074
+ browsePath = d.path;
1075
+ const up = `decodeURIComponent('${encodeURIComponent(d.parent)}')`;
1076
+ box.innerHTML = `<div class="browser">
1077
+ <div class="crumb">${esc(shortP(d.path))}</div>
1078
+ <div class="dirs">
1079
+ <button onclick="browseFolders(${up})">⬑ ..</button>
1080
+ ${d.dirs.map(n => {
1081
+ const full = `decodeURIComponent('${encodeURIComponent((d.path.endsWith("/") ? d.path : d.path + "/") + n)}')`;
1082
+ return `<button onclick="browseFolders(${full})">${esc(n)}/</button>`;
1083
+ }).join("")}
1084
+ </div>
1085
+ <div style="display:flex; gap:8px; margin-top:10px">
1086
+ <button class="add" onclick="pickRoot()">scan this folder</button>
1087
+ <button class="ghost" onclick="document.getElementById('cfg-browser').style.display='none'">cancel</button>
1088
+ </div>
1089
+ </div>`;
1090
+ } catch (e) { toast(e.message, true); }
1091
+ }
1092
+ function pickRoot() {
1093
+ if (browsePath && !pending.roots.includes(browsePath)) {
1094
+ pending.roots.push(browsePath);
1095
+ pending.openRoots.add(browsePath);
1096
+ }
1097
+ document.getElementById("cfg-browser").style.display = "none";
1098
+ renderRoots();
1099
+ }
1100
+
1101
+ async function saveConfig() {
1102
+ try {
1103
+ await api("/api/config", {
1104
+ projectRoots: pending.roots,
1105
+ excludes: [...pending.excludes],
1106
+ categoryOrder: pending.cats,
1107
+ });
1108
+ toast("settings saved");
1109
+ document.getElementById("settings").style.display = "none";
1110
+ await loadMeta();
1111
+ await loadProjects(true);
1112
+ } catch (e) { toast(e.message, true); }
1113
+ }
1114
+ async function load() { await Promise.all([loadServices(), loadProjects()]); }
1115
+ async function loadMeta() {
1116
+ try {
1117
+ const m = await api("/api/meta");
1118
+ meta = m;
1119
+ if (m.categoryOrder && m.categoryOrder.length) CAT_ORDER = [...m.categoryOrder, "Uncategorized"];
1120
+ document.getElementById("metaPaths").textContent = `v${m.version}`;
1121
+ document.getElementById("metaPaths").title = `config: ${shortP(m.configPath)} · logs: ${shortP(m.logDir)}`;
1122
+ document.getElementById("filter").placeholder =
1123
+ `filter ${m.projectRoots.map(shortP).join(", ")}…`;
1124
+ renderProjects();
1125
+ } catch {}
1126
+ }
1127
+
1128
+ load(); loadMeta(); loadInfra();
1129
+ setInterval(() => { if (document.visibilityState === "visible") loadInfra(); }, 15000);
1130
+ setInterval(() => { if (document.visibilityState === "visible") loadServices(); }, 5000);
1131
+ setInterval(() => { if (document.visibilityState === "visible" && !editGuard()) loadProjects(); }, 60000);
1132
+ </script>
1133
+ </body>
1134
+ </html>