@bakapiano/ccsm 0.16.0 → 0.17.2
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/bin/ccsm.js +33 -0
- package/lib/config.js +20 -1
- package/lib/folders.js +23 -4
- package/package.json +1 -1
- package/public/css/feedback.css +115 -0
- package/public/css/sidebar.css +17 -0
- package/public/js/api.js +15 -2
- package/public/js/components/App.js +2 -2
- package/public/js/components/EntityFormModal.js +22 -10
- package/public/js/components/HealthOverlay.js +91 -0
- package/public/js/components/KeybindingRecorder.js +138 -0
- package/public/js/components/Sidebar.js +87 -15
- package/public/js/keybindings.js +36 -6
- package/public/js/pages/AboutPage.js +33 -9
- package/public/js/pages/ConfigurePage.js +49 -49
- package/public/js/state.js +22 -3
- package/scripts/dev.js +46 -0
- package/scripts/install.js +14 -19
- package/scripts/upgrade-helper.js +551 -62
- package/server.js +43 -4
package/bin/ccsm.js
CHANGED
|
@@ -39,6 +39,15 @@ function loadPreferredPort() {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Cheap "is this pid still alive" check using kill(pid, 0). Returns
|
|
43
|
+
// true for live pids we own, also true for pids in other security
|
|
44
|
+
// contexts (EPERM means it exists, we just can't signal it).
|
|
45
|
+
function pidAlive(pid) {
|
|
46
|
+
if (!pid) return false;
|
|
47
|
+
try { process.kill(pid, 0); return true; }
|
|
48
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function probe(port, timeoutMs = 800) {
|
|
43
52
|
return new Promise((resolve) => {
|
|
44
53
|
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
@@ -106,6 +115,30 @@ function isSameVersion(running) {
|
|
|
106
115
|
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
107
116
|
const port = loadPreferredPort();
|
|
108
117
|
|
|
118
|
+
// Upgrade-in-progress guard. The updater helper writes
|
|
119
|
+
// ~/.ccsm/.upgrade.lock at start. If a ccsm:// click (or any other
|
|
120
|
+
// launcher trigger) races during an in-flight install, spawning a
|
|
121
|
+
// new server would: (a) fight npm for the package dir, EBUSY; or
|
|
122
|
+
// (b) bind port 7777 before the helper's own respawn does. Either
|
|
123
|
+
// way the upgrade derails. Bail out instead — the helper's UI on
|
|
124
|
+
// 7779 is already showing the user what's happening.
|
|
125
|
+
const lockPath = path.join(HOME, '.upgrade.lock');
|
|
126
|
+
try {
|
|
127
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
128
|
+
const lock = JSON.parse(raw);
|
|
129
|
+
const ageMs = Date.now() - (lock.startedAt || 0);
|
|
130
|
+
const ownerAlive = lock.pid ? pidAlive(lock.pid) : false;
|
|
131
|
+
if (ownerAlive && ageMs < 10 * 60_000) {
|
|
132
|
+
console.log(`ccsm: upgrade in progress (helper pid=${lock.pid}, ${Math.round(ageMs/1000)}s ago, target=${lock.target || '?'})`);
|
|
133
|
+
console.log(` see http://localhost:${lock.helperPort || 7779}/ for live progress`);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
// Stale lock (pid dead OR > 10min) — clean up and continue.
|
|
137
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
138
|
+
} catch {
|
|
139
|
+
// ENOENT or parse error → no lock, proceed.
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
// Case 1: existing instance on the preferred port
|
|
110
143
|
let existing = await probe(port);
|
|
111
144
|
|
package/lib/config.js
CHANGED
|
@@ -152,7 +152,7 @@ function mergeWithDefaults(partial) {
|
|
|
152
152
|
// Normalize per-CLI fields.
|
|
153
153
|
out.clis = out.clis.map((c) => {
|
|
154
154
|
const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
|
|
155
|
-
|
|
155
|
+
const normalized = {
|
|
156
156
|
...rest,
|
|
157
157
|
args: Array.isArray(rest.args) ? rest.args : [],
|
|
158
158
|
resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
|
|
@@ -161,6 +161,25 @@ function mergeWithDefaults(partial) {
|
|
|
161
161
|
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
162
162
|
builtin: !!rest.builtin,
|
|
163
163
|
};
|
|
164
|
+
// Type-based fallback for non-builtin CLIs (wrappers like `ccp`
|
|
165
|
+
// that just call claude under the hood). If user picked
|
|
166
|
+
// type='claude' but left newSessionIdArgs / resumeIdArgs blank,
|
|
167
|
+
// assume they want the same args claude / copilot / codex use
|
|
168
|
+
// canonically — without this the wrapped CLI gets spawned with
|
|
169
|
+
// no UUID and ccsm can never recapture the upstream session.
|
|
170
|
+
// Builtins are already handled by the loop above with `def`.
|
|
171
|
+
if (!normalized.builtin && normalized.type !== 'other') {
|
|
172
|
+
const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
|
|
173
|
+
if (template) {
|
|
174
|
+
if (normalized.newSessionIdArgs.length === 0) {
|
|
175
|
+
normalized.newSessionIdArgs = [...template.newSessionIdArgs];
|
|
176
|
+
}
|
|
177
|
+
if (normalized.resumeIdArgs.length === 0) {
|
|
178
|
+
normalized.resumeIdArgs = [...template.resumeIdArgs];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return normalized;
|
|
164
183
|
});
|
|
165
184
|
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
166
185
|
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
package/lib/folders.js
CHANGED
|
@@ -16,15 +16,30 @@ const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
|
16
16
|
|
|
17
17
|
const FILE = path.join(DATA_DIR, 'folders.json');
|
|
18
18
|
|
|
19
|
+
// Sentinel for the synthetic "Unsorted" folder. Sessions with
|
|
20
|
+
// folderId === null render under it. We always materialize it in the
|
|
21
|
+
// returned list so the sidebar can drag-reorder it like a real folder,
|
|
22
|
+
// but create/update/delete refuse to touch it.
|
|
23
|
+
const UNSORTED_ID = 'unsorted';
|
|
24
|
+
function unsortedDefault(order) {
|
|
25
|
+
return { id: UNSORTED_ID, name: 'Unsorted', order, builtin: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
async function loadAll() {
|
|
29
|
+
let list = [];
|
|
20
30
|
try {
|
|
21
31
|
const raw = await fs.readFile(FILE, 'utf8');
|
|
22
32
|
const j = JSON.parse(raw);
|
|
23
|
-
|
|
33
|
+
if (Array.isArray(j)) list = j;
|
|
24
34
|
} catch (e) {
|
|
25
|
-
if (e.code
|
|
26
|
-
throw e;
|
|
35
|
+
if (e.code !== 'ENOENT') throw e;
|
|
27
36
|
}
|
|
37
|
+
// Ensure the synthetic Unsorted entry is present. New install: append
|
|
38
|
+
// at the end. Existing install pre-Unsorted-draggable: same.
|
|
39
|
+
if (!list.find((f) => f.id === UNSORTED_ID)) {
|
|
40
|
+
list = list.concat(unsortedDefault(list.length));
|
|
41
|
+
}
|
|
42
|
+
return list;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
async function saveAll(list) {
|
|
@@ -52,13 +67,16 @@ async function create({ name }) {
|
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
async function update(id, patch) {
|
|
70
|
+
if (id === UNSORTED_ID && typeof patch.name === 'string') {
|
|
71
|
+
throw new Error('cannot rename the Unsorted bucket');
|
|
72
|
+
}
|
|
55
73
|
return withFileLock(FILE, async () => {
|
|
56
74
|
const list = await loadAll();
|
|
57
75
|
const idx = list.findIndex((f) => f.id === id);
|
|
58
76
|
if (idx < 0) return null;
|
|
59
77
|
// Allow rename + reorder, ignore other keys.
|
|
60
78
|
const allowed = {};
|
|
61
|
-
if (typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
79
|
+
if (id !== UNSORTED_ID && typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
62
80
|
if (typeof patch.order === 'number') allowed.order = patch.order;
|
|
63
81
|
list[idx] = { ...list[idx], ...allowed };
|
|
64
82
|
await saveAll(list);
|
|
@@ -67,6 +85,7 @@ async function update(id, patch) {
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
async function remove(id) {
|
|
88
|
+
if (id === UNSORTED_ID) throw new Error('cannot delete the Unsorted bucket');
|
|
70
89
|
return withFileLock(FILE, async () => {
|
|
71
90
|
const list = await loadAll();
|
|
72
91
|
const idx = list.findIndex((f) => f.id === id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/feedback.css
CHANGED
|
@@ -301,3 +301,118 @@
|
|
|
301
301
|
0%, 100% { box-shadow: 0 0 0 0 rgba(26, 24, 21, 0.25); }
|
|
302
302
|
50% { box-shadow: 0 0 0 4px rgba(26, 24, 21, 0); }
|
|
303
303
|
}
|
|
304
|
+
|
|
305
|
+
/* HealthOverlay reuses .offline-overlay / .offline-card above. The
|
|
306
|
+
only extra is the spinner shown during the "Checking…" phase — sized
|
|
307
|
+
to fill the .offline-brand slot in place of the BrandMark logo. */
|
|
308
|
+
.health-spinner {
|
|
309
|
+
width: 36px;
|
|
310
|
+
height: 36px;
|
|
311
|
+
border: 3px solid var(--border);
|
|
312
|
+
border-top-color: var(--ink);
|
|
313
|
+
border-radius: 50%;
|
|
314
|
+
animation: health-spin 0.8s linear infinite;
|
|
315
|
+
}
|
|
316
|
+
@keyframes health-spin {
|
|
317
|
+
to { transform: rotate(360deg); }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ── Keybinding recorder modal ───────────────────────────────────── */
|
|
321
|
+
.kbd-recorder-overlay {
|
|
322
|
+
position: fixed;
|
|
323
|
+
inset: 0;
|
|
324
|
+
z-index: 9999;
|
|
325
|
+
background: rgba(26, 24, 21, 0.5);
|
|
326
|
+
backdrop-filter: blur(4px);
|
|
327
|
+
-webkit-backdrop-filter: blur(4px);
|
|
328
|
+
display: flex;
|
|
329
|
+
align-items: center;
|
|
330
|
+
justify-content: center;
|
|
331
|
+
animation: kbd-rec-fade-in 0.16s ease-out;
|
|
332
|
+
}
|
|
333
|
+
@keyframes kbd-rec-fade-in {
|
|
334
|
+
from { opacity: 0; }
|
|
335
|
+
to { opacity: 1; }
|
|
336
|
+
}
|
|
337
|
+
.kbd-recorder-card {
|
|
338
|
+
background: var(--bg-elev);
|
|
339
|
+
border: 1px solid var(--border);
|
|
340
|
+
border-radius: 14px;
|
|
341
|
+
padding: 32px 40px;
|
|
342
|
+
min-width: 380px;
|
|
343
|
+
max-width: 520px;
|
|
344
|
+
text-align: center;
|
|
345
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
|
346
|
+
font-family: var(--body);
|
|
347
|
+
}
|
|
348
|
+
.kbd-recorder-label {
|
|
349
|
+
font-size: 12.5px;
|
|
350
|
+
color: var(--ink-mid);
|
|
351
|
+
letter-spacing: 0.02em;
|
|
352
|
+
text-transform: uppercase;
|
|
353
|
+
margin-bottom: 6px;
|
|
354
|
+
}
|
|
355
|
+
.kbd-recorder-action {
|
|
356
|
+
font-size: 18px;
|
|
357
|
+
font-weight: 500;
|
|
358
|
+
letter-spacing: -0.01em;
|
|
359
|
+
color: var(--ink);
|
|
360
|
+
margin-bottom: 24px;
|
|
361
|
+
}
|
|
362
|
+
.kbd-recorder-keys {
|
|
363
|
+
display: flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
justify-content: center;
|
|
366
|
+
gap: 8px;
|
|
367
|
+
min-height: 48px;
|
|
368
|
+
margin-bottom: 20px;
|
|
369
|
+
flex-wrap: wrap;
|
|
370
|
+
}
|
|
371
|
+
.kbd-recorder-key {
|
|
372
|
+
display: inline-flex;
|
|
373
|
+
align-items: center;
|
|
374
|
+
justify-content: center;
|
|
375
|
+
min-width: 36px;
|
|
376
|
+
height: 36px;
|
|
377
|
+
padding: 0 10px;
|
|
378
|
+
background: var(--bg);
|
|
379
|
+
border: 1px solid var(--border);
|
|
380
|
+
border-bottom-width: 2px;
|
|
381
|
+
border-radius: 6px;
|
|
382
|
+
font-family: var(--mono);
|
|
383
|
+
font-size: 13px;
|
|
384
|
+
font-weight: 500;
|
|
385
|
+
color: var(--ink);
|
|
386
|
+
animation: kbd-rec-pop 0.12s ease-out;
|
|
387
|
+
}
|
|
388
|
+
@keyframes kbd-rec-pop {
|
|
389
|
+
from { transform: scale(0.85); opacity: 0.4; }
|
|
390
|
+
to { transform: scale(1); opacity: 1; }
|
|
391
|
+
}
|
|
392
|
+
.kbd-recorder-plus {
|
|
393
|
+
color: var(--ink-muted);
|
|
394
|
+
font-size: 14px;
|
|
395
|
+
font-weight: 300;
|
|
396
|
+
}
|
|
397
|
+
.kbd-recorder-placeholder {
|
|
398
|
+
color: var(--ink-muted);
|
|
399
|
+
font-size: 13px;
|
|
400
|
+
}
|
|
401
|
+
.kbd-recorder-actions {
|
|
402
|
+
display: flex;
|
|
403
|
+
justify-content: center;
|
|
404
|
+
gap: 8px;
|
|
405
|
+
margin-bottom: 14px;
|
|
406
|
+
}
|
|
407
|
+
.kbd-recorder-hint {
|
|
408
|
+
font-size: 11.5px;
|
|
409
|
+
color: var(--ink-muted);
|
|
410
|
+
}
|
|
411
|
+
.kbd-recorder-hint kbd {
|
|
412
|
+
font-family: var(--mono);
|
|
413
|
+
font-size: 11px;
|
|
414
|
+
background: var(--bg);
|
|
415
|
+
border: 1px solid var(--border);
|
|
416
|
+
border-radius: 3px;
|
|
417
|
+
padding: 1px 5px;
|
|
418
|
+
}
|
package/public/css/sidebar.css
CHANGED
|
@@ -545,6 +545,23 @@ body.is-resizing-sidebar * {
|
|
|
545
545
|
background: var(--sidebar-active);
|
|
546
546
|
font-weight: 500;
|
|
547
547
|
}
|
|
548
|
+
/* Drop-line shown above the row when a sibling session is being
|
|
549
|
+
dragged over it for within-folder reorder. Top-only inset shadow
|
|
550
|
+
reads as a 2px insertion mark without shifting layout. */
|
|
551
|
+
.tree-session.is-reorder-target {
|
|
552
|
+
position: relative;
|
|
553
|
+
}
|
|
554
|
+
.tree-session.is-reorder-target::before {
|
|
555
|
+
content: "";
|
|
556
|
+
position: absolute;
|
|
557
|
+
top: -1px;
|
|
558
|
+
left: 4px;
|
|
559
|
+
right: 4px;
|
|
560
|
+
height: 2px;
|
|
561
|
+
background: var(--ink-mid);
|
|
562
|
+
border-radius: 1px;
|
|
563
|
+
pointer-events: none;
|
|
564
|
+
}
|
|
548
565
|
/* Status dot · deliberately understated. The earlier version had a
|
|
549
566
|
green dot + soft glow + expanding halo pulse; in a sidebar with
|
|
550
567
|
eight running sessions it read as a row of strobing alerts. Now:
|
package/public/js/api.js
CHANGED
|
@@ -187,6 +187,11 @@ export async function setSessionFolder(sessionId, folderId) {
|
|
|
187
187
|
await loadSessions();
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
export async function reorderSessions(folderId, ids) {
|
|
191
|
+
await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
|
|
192
|
+
await loadSessions();
|
|
193
|
+
}
|
|
194
|
+
|
|
190
195
|
export async function setSessionTitle(sessionId, title) {
|
|
191
196
|
await api('PUT', `/api/sessions/${sessionId}`, { title });
|
|
192
197
|
await loadSessions();
|
|
@@ -280,6 +285,7 @@ export async function restartBackend() {
|
|
|
280
285
|
return api('POST', '/api/restart');
|
|
281
286
|
}
|
|
282
287
|
|
|
288
|
+
let consecutiveOffline = 0;
|
|
283
289
|
export async function pollHealth() {
|
|
284
290
|
const ctrl = new AbortController();
|
|
285
291
|
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
@@ -287,9 +293,16 @@ export async function pollHealth() {
|
|
|
287
293
|
const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
|
|
288
294
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
289
295
|
const j = await r.json();
|
|
290
|
-
|
|
296
|
+
consecutiveOffline = 0;
|
|
297
|
+
S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
|
|
298
|
+
if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
|
|
291
299
|
} catch (e) {
|
|
292
|
-
|
|
300
|
+
consecutiveOffline++;
|
|
301
|
+
S.serverHealth.value = {
|
|
302
|
+
state: 'offline',
|
|
303
|
+
error: String(e.message || e),
|
|
304
|
+
failureCount: consecutiveOffline,
|
|
305
|
+
};
|
|
293
306
|
} finally {
|
|
294
307
|
clearTimeout(t);
|
|
295
308
|
}
|
|
@@ -3,7 +3,7 @@ import { activeTab } from '../state.js';
|
|
|
3
3
|
import { Sidebar } from './Sidebar.js';
|
|
4
4
|
import { Toast } from './Toast.js';
|
|
5
5
|
import { DialogHost } from './DialogHost.js';
|
|
6
|
-
import {
|
|
6
|
+
import { HealthOverlay } from './HealthOverlay.js';
|
|
7
7
|
import { SessionsPage } from '../pages/SessionsPage.js';
|
|
8
8
|
import { LaunchPage } from '../pages/LaunchPage.js';
|
|
9
9
|
import { ConfigurePage } from '../pages/ConfigurePage.js';
|
|
@@ -28,8 +28,8 @@ export function App() {
|
|
|
28
28
|
${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
|
|
29
29
|
</div>
|
|
30
30
|
</main>
|
|
31
|
-
<${OfflineBanner} />
|
|
32
31
|
<${Toast} />
|
|
33
32
|
<${DialogHost} />
|
|
33
|
+
<${HealthOverlay} />
|
|
34
34
|
</div>`;
|
|
35
35
|
}
|
|
@@ -26,7 +26,19 @@ export function EntityFormModal({
|
|
|
26
26
|
const [testing, setTesting] = useState(false);
|
|
27
27
|
const [testResult, setTestResult] = useState(null);
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// A field is read-only if its key is in the static `readOnlyKeys`
|
|
30
|
+
// prop OR its own `readOnly` predicate (called with the current
|
|
31
|
+
// draft) returns true. The predicate lets a field react to other
|
|
32
|
+
// fields' values — e.g. lock newSessionIdArgs once a known `type`
|
|
33
|
+
// is picked, since those args are an integration contract with the
|
|
34
|
+
// upstream CLI, not a user knob.
|
|
35
|
+
const isReadOnly = (field) => {
|
|
36
|
+
if (readOnlyKeys.includes(field.key)) return true;
|
|
37
|
+
if (typeof field.readOnly === 'function') {
|
|
38
|
+
try { return !!field.readOnly(draft); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
return !!field.readOnly;
|
|
41
|
+
};
|
|
30
42
|
|
|
31
43
|
const submit = async (ev) => {
|
|
32
44
|
ev?.preventDefault?.();
|
|
@@ -61,7 +73,7 @@ export function EntityFormModal({
|
|
|
61
73
|
<span class="entity-field-label">${f.label}</span>
|
|
62
74
|
${f.type === 'select' ? html`
|
|
63
75
|
<select class="input" value=${draft[f.key] || ''}
|
|
64
|
-
disabled=${isReadOnly(f
|
|
76
|
+
disabled=${isReadOnly(f)}
|
|
65
77
|
onChange=${(e) => {
|
|
66
78
|
const next = { ...draft, [f.key]: e.target.value };
|
|
67
79
|
const sideEffects = f.onChange?.(e.target.value, next);
|
|
@@ -71,13 +83,13 @@ export function EntityFormModal({
|
|
|
71
83
|
<option value=${opt.value}>${opt.label}</option>`)}
|
|
72
84
|
</select>
|
|
73
85
|
` : f.type === 'iconRadio' ? html`
|
|
74
|
-
<div class=${`icon-radio${isReadOnly(f
|
|
86
|
+
<div class=${`icon-radio${isReadOnly(f) ? ' is-disabled' : ''}`}>
|
|
75
87
|
${(f.options || []).map((opt) => html`
|
|
76
88
|
<button type="button" key=${opt.value}
|
|
77
89
|
class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
|
|
78
|
-
disabled=${isReadOnly(f
|
|
90
|
+
disabled=${isReadOnly(f)}
|
|
79
91
|
onClick=${() => {
|
|
80
|
-
if (isReadOnly(f
|
|
92
|
+
if (isReadOnly(f)) return;
|
|
81
93
|
const next = { ...draft, [f.key]: opt.value };
|
|
82
94
|
const sideEffects = f.onChange?.(opt.value, next);
|
|
83
95
|
setDraft(sideEffects ? { ...next, ...sideEffects } : next);
|
|
@@ -89,20 +101,20 @@ export function EntityFormModal({
|
|
|
89
101
|
` : f.type === 'checkbox' ? html`
|
|
90
102
|
<span class="entity-checkbox-row">
|
|
91
103
|
<input type="checkbox" checked=${!!draft[f.key]}
|
|
92
|
-
disabled=${isReadOnly(f
|
|
104
|
+
disabled=${isReadOnly(f)}
|
|
93
105
|
onChange=${(e) => setDraft({ ...draft, [f.key]: e.target.checked })} />
|
|
94
|
-
${f.hint ? html`<span class="entity-field-hint">${f.hint}</span>` : null}
|
|
106
|
+
${f.hint ? html`<span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
|
|
95
107
|
</span>
|
|
96
108
|
` : html`
|
|
97
109
|
<input type=${f.type || 'text'}
|
|
98
110
|
class=${`input${f.mono ? ' mono' : ''}`}
|
|
99
111
|
placeholder=${f.placeholder || ''}
|
|
100
112
|
value=${draft[f.key] || ''}
|
|
101
|
-
readonly=${isReadOnly(f
|
|
113
|
+
readonly=${isReadOnly(f)}
|
|
102
114
|
onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
|
|
103
|
-
autoFocus=${f.autoFocus && !isReadOnly(f
|
|
115
|
+
autoFocus=${f.autoFocus && !isReadOnly(f)} />`}
|
|
104
116
|
${f.hint && f.type !== 'checkbox' ? html`
|
|
105
|
-
<span class="entity-field-hint">${f.hint}</span>` : null}
|
|
117
|
+
<span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
|
|
106
118
|
</label>`)}
|
|
107
119
|
${testResult ? html`
|
|
108
120
|
<div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Full-screen modal shown while the backend is unreachable.
|
|
2
|
+
//
|
|
3
|
+
// Two phases:
|
|
4
|
+
// - Early (failureCount < THRESHOLD): "Checking backend health…" with
|
|
5
|
+
// a spinner. Most outages resolve in one or two ticks — no need to
|
|
6
|
+
// scare the user.
|
|
7
|
+
// - Persistent (failureCount >= THRESHOLD): "Backend not running.
|
|
8
|
+
// Start backend" with a button that fires the ccsm:// protocol so
|
|
9
|
+
// Windows' registered launcher.vbs spawns ccsm.
|
|
10
|
+
//
|
|
11
|
+
// We never auto-resume. The user has to click the button — protects
|
|
12
|
+
// against repeated wake attempts during an in-flight upgrade or a
|
|
13
|
+
// crash loop.
|
|
14
|
+
//
|
|
15
|
+
// While offline we drive a faster (1.5s) poll loop directly so the
|
|
16
|
+
// modal dismisses promptly when the backend comes back, without
|
|
17
|
+
// waiting for the main 5s refresh interval.
|
|
18
|
+
|
|
19
|
+
import { html } from '../html.js';
|
|
20
|
+
import { useEffect } from 'preact/hooks';
|
|
21
|
+
import { serverHealth, hasBootedOnline } from '../state.js';
|
|
22
|
+
import { pollHealth, refreshAll } from '../api.js';
|
|
23
|
+
import { BrandMark } from '../icons.js';
|
|
24
|
+
|
|
25
|
+
const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
|
|
26
|
+
const FAST_POLL_MS = 1500;
|
|
27
|
+
|
|
28
|
+
export function HealthOverlay() {
|
|
29
|
+
const h = serverHealth.value;
|
|
30
|
+
const offline = h.state === 'offline';
|
|
31
|
+
const count = h.failureCount || 0;
|
|
32
|
+
const everSeen = hasBootedOnline.value;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!offline) return;
|
|
36
|
+
const id = setInterval(() => { pollHealth(); }, FAST_POLL_MS);
|
|
37
|
+
return () => clearInterval(id);
|
|
38
|
+
}, [offline]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!offline && everSeen) {
|
|
42
|
+
refreshAll().catch(() => {});
|
|
43
|
+
}
|
|
44
|
+
}, [offline]);
|
|
45
|
+
|
|
46
|
+
if (!offline || !everSeen) return null;
|
|
47
|
+
|
|
48
|
+
const showStart = count >= THRESHOLD;
|
|
49
|
+
|
|
50
|
+
// Reuses the .offline-overlay / .offline-card classes so the card
|
|
51
|
+
// layout (brand mark, big title, copy, primary action button,
|
|
52
|
+
// collapsible npm-install fallback) matches what the OfflineBanner
|
|
53
|
+
// used to render. HealthOverlay differs only in the two states:
|
|
54
|
+
// early polls show a spinner + "Checking…" instead of the static
|
|
55
|
+
// "Backend not running" card.
|
|
56
|
+
return html`
|
|
57
|
+
<div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
|
58
|
+
<div class="offline-card">
|
|
59
|
+
<div class="offline-brand">${
|
|
60
|
+
showStart
|
|
61
|
+
? html`<${BrandMark} />`
|
|
62
|
+
: html`<div class="health-spinner" aria-hidden="true"></div>`
|
|
63
|
+
}</div>
|
|
64
|
+
${!showStart ? html`
|
|
65
|
+
<h1 class="offline-title">Checking backend health…</h1>
|
|
66
|
+
<p class="offline-copy">
|
|
67
|
+
${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
|
|
68
|
+
</p>
|
|
69
|
+
` : html`
|
|
70
|
+
<h1 class="offline-title">Backend not running</h1>
|
|
71
|
+
<p class="offline-copy">
|
|
72
|
+
ccsm's local backend isn't reachable. Wake it manually below — we won't
|
|
73
|
+
auto-restart. Windows may ask once for permission; tick <em>Always allow</em>
|
|
74
|
+
to silence future prompts.
|
|
75
|
+
</p>
|
|
76
|
+
<div class="offline-actions">
|
|
77
|
+
<a class="action primary big" href="ccsm://start">Start backend</a>
|
|
78
|
+
</div>
|
|
79
|
+
<details class="offline-fallback">
|
|
80
|
+
<summary>Don't have ccsm installed?</summary>
|
|
81
|
+
<div class="offline-fallback-body">
|
|
82
|
+
<p>Install once via npm, then come back here:</p>
|
|
83
|
+
<pre><code>npm i -g @bakapiano/ccsm</code></pre>
|
|
84
|
+
<p>Or run a one-shot trial without installing:</p>
|
|
85
|
+
<pre><code>npx @bakapiano/ccsm</code></pre>
|
|
86
|
+
</div>
|
|
87
|
+
</details>
|
|
88
|
+
`}
|
|
89
|
+
</div>
|
|
90
|
+
</div>`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Full-screen modal that captures a key combo with a live preview of
|
|
2
|
+
// what the user is currently holding. Click to record → push modifiers
|
|
3
|
+
// in real time → release / press a non-modifier key to commit → close.
|
|
4
|
+
// Esc with no modifiers cancels.
|
|
5
|
+
|
|
6
|
+
import { html } from '../html.js';
|
|
7
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
8
|
+
|
|
9
|
+
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
|
|
10
|
+
|
|
11
|
+
function modsFromEvent(ev) {
|
|
12
|
+
const out = [];
|
|
13
|
+
if (ev.ctrlKey) out.push('Ctrl');
|
|
14
|
+
if (ev.altKey) out.push('Alt');
|
|
15
|
+
if (ev.shiftKey) out.push('Shift');
|
|
16
|
+
if (ev.metaKey) out.push('Meta');
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function prettyKey(k) {
|
|
21
|
+
if (k === 'ArrowUp') return '↑';
|
|
22
|
+
if (k === 'ArrowDown') return '↓';
|
|
23
|
+
if (k === 'ArrowLeft') return '←';
|
|
24
|
+
if (k === 'ArrowRight') return '→';
|
|
25
|
+
if (k === ' ') return 'Space';
|
|
26
|
+
if (k === 'Escape') return 'Esc';
|
|
27
|
+
if (/^[a-z]$/.test(k)) return k.toUpperCase();
|
|
28
|
+
return k;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function KeybindingRecorder({ actionLabel, onCommit, onCancel }) {
|
|
32
|
+
// While `captured` is null, we're listening — display reflects whatever
|
|
33
|
+
// is currently held. Once a non-modifier key lands, we freeze that
|
|
34
|
+
// combo into `captured` and surface explicit Confirm / Try again
|
|
35
|
+
// buttons. Pressing another non-modifier key replaces the captured
|
|
36
|
+
// combo (useful when the user mis-pressed and wants to retry without
|
|
37
|
+
// clicking).
|
|
38
|
+
const [mods, setMods] = useState([]);
|
|
39
|
+
const [captured, setCaptured] = useState(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onDown = (ev) => {
|
|
43
|
+
ev.preventDefault();
|
|
44
|
+
ev.stopPropagation();
|
|
45
|
+
|
|
46
|
+
// Esc with no modifiers = cancel. Esc-as-shortcut is allowed when
|
|
47
|
+
// any modifier is also held.
|
|
48
|
+
if (ev.key === 'Escape' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
|
|
49
|
+
onCancel();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Enter while a combo is captured = confirm (so the user can finish
|
|
54
|
+
// with a single hand on the keyboard, no mouse round-trip).
|
|
55
|
+
if (ev.key === 'Enter' && captured && !ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
|
|
56
|
+
onCommit(captured);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const currentMods = modsFromEvent(ev);
|
|
61
|
+
if (MODIFIER_KEYS.has(ev.key)) {
|
|
62
|
+
setMods(currentMods);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Real key landed — freeze it; user still has to confirm.
|
|
67
|
+
let k = ev.key;
|
|
68
|
+
if (/^[a-z]$/.test(k)) k = k.toUpperCase();
|
|
69
|
+
const combo = [...currentMods, k].join('+');
|
|
70
|
+
setCaptured(combo);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onUp = (ev) => {
|
|
74
|
+
setMods(modsFromEvent(ev));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
window.addEventListener('keydown', onDown, true);
|
|
78
|
+
window.addEventListener('keyup', onUp, true);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener('keydown', onDown, true);
|
|
81
|
+
window.removeEventListener('keyup', onUp, true);
|
|
82
|
+
};
|
|
83
|
+
}, [onCommit, onCancel, captured]);
|
|
84
|
+
|
|
85
|
+
// The keys shown in the keycap row. While listening: live modifier
|
|
86
|
+
// state + last-pressed key (if captured replaced live state). When
|
|
87
|
+
// captured: parse the frozen combo back into parts so we show the
|
|
88
|
+
// exact thing about to be saved.
|
|
89
|
+
let parts;
|
|
90
|
+
if (captured) {
|
|
91
|
+
parts = captured.split('+');
|
|
92
|
+
} else {
|
|
93
|
+
parts = [...mods];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<div class="kbd-recorder-overlay" role="dialog" aria-modal="true"
|
|
98
|
+
onClick=${onCancel}>
|
|
99
|
+
<div class="kbd-recorder-card" onClick=${(ev) => ev.stopPropagation()}>
|
|
100
|
+
<div class="kbd-recorder-label">
|
|
101
|
+
${captured ? 'Captured shortcut for' : 'Press a shortcut for'}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="kbd-recorder-action">${actionLabel}</div>
|
|
104
|
+
|
|
105
|
+
<div class="kbd-recorder-keys">
|
|
106
|
+
${parts.length === 0
|
|
107
|
+
? html`<span class="kbd-recorder-placeholder">Press any key combo…</span>`
|
|
108
|
+
: parts.map((p, i) => html`
|
|
109
|
+
<span class="kbd-recorder-key" key=${i}>${prettyKey(p)}</span>
|
|
110
|
+
${i < parts.length - 1
|
|
111
|
+
? html`<span class="kbd-recorder-plus">+</span>`
|
|
112
|
+
: null}
|
|
113
|
+
`)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
${captured ? html`
|
|
117
|
+
<div class="kbd-recorder-actions">
|
|
118
|
+
<button class="action small subtle" onClick=${() => setCaptured(null)}>
|
|
119
|
+
Try again
|
|
120
|
+
</button>
|
|
121
|
+
<button class="action small subtle" onClick=${onCancel}>
|
|
122
|
+
Cancel
|
|
123
|
+
</button>
|
|
124
|
+
<button class="action small primary" onClick=${() => onCommit(captured)}>
|
|
125
|
+
Confirm
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="kbd-recorder-hint">
|
|
129
|
+
<kbd>Enter</kbd> to confirm · <kbd>Esc</kbd> to cancel · press another combo to replace
|
|
130
|
+
</div>
|
|
131
|
+
` : html`
|
|
132
|
+
<div class="kbd-recorder-hint">
|
|
133
|
+
<kbd>Esc</kbd> to cancel · click outside to dismiss
|
|
134
|
+
</div>
|
|
135
|
+
`}
|
|
136
|
+
</div>
|
|
137
|
+
</div>`;
|
|
138
|
+
}
|