@bakapiano/ccsm 0.10.3 → 0.11.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/CLAUDE.md +475 -475
- package/README.md +190 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliSessionWatcher.js +249 -249
- package/lib/config.js +185 -185
- package/lib/folders.js +96 -96
- package/lib/localCliSessions.js +489 -177
- package/lib/persistedSessions.js +134 -134
- package/lib/webTerminal.js +208 -208
- package/lib/workspace.js +230 -255
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -183
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +616 -601
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -79
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1596 -1375
- package/public/index.html +105 -103
- package/public/js/api.js +272 -260
- package/public/js/components/AdoptModal.js +343 -171
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +105 -105
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +270 -270
- package/public/js/components/TerminalView.js +298 -298
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +140 -140
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +475 -487
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +97 -97
- package/public/js/state.js +231 -231
- package/public/manifest.webmanifest +15 -15
- package/scripts/install.js +137 -137
- package/server.js +1126 -1117
package/public/js/main.js
CHANGED
|
@@ -1,140 +1,140 @@
|
|
|
1
|
-
// Entry. Loads persisted ui state → boots data → mounts App → spins up
|
|
2
|
-
// the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
|
|
3
|
-
// the mount root.
|
|
4
|
-
|
|
5
|
-
import { render } from 'preact';
|
|
6
|
-
import { effect } from '@preact/signals';
|
|
7
|
-
import { html } from './html.js';
|
|
8
|
-
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, serverHealth } from './state.js';
|
|
9
|
-
import { httpBase } from './backend.js';
|
|
10
|
-
import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
|
|
11
|
-
import { setToast } from './toast.js';
|
|
12
|
-
import { App } from './components/App.js';
|
|
13
|
-
|
|
14
|
-
loadPersisted();
|
|
15
|
-
// Window/tab title tracks the live backend version — "CCSM v0.10.1" once
|
|
16
|
-
// /api/health responds, "CCSM" before then. A MutationObserver guards
|
|
17
|
-
// against Chromium standalone builds that occasionally try to inject the
|
|
18
|
-
// URL into the title bar; it accepts any "CCSM..." string we set and
|
|
19
|
-
// resets anything else.
|
|
20
|
-
function desiredTitle() {
|
|
21
|
-
const v = serverHealth.value.version;
|
|
22
|
-
return v ? `CCSM v${v}` : 'CCSM';
|
|
23
|
-
}
|
|
24
|
-
let expected = desiredTitle();
|
|
25
|
-
function lockTitle() { if (document.title !== expected) document.title = expected; }
|
|
26
|
-
lockTitle();
|
|
27
|
-
new MutationObserver(lockTitle).observe(
|
|
28
|
-
document.querySelector('title') || document.head,
|
|
29
|
-
{ childList: true, subtree: true, characterData: true }
|
|
30
|
-
);
|
|
31
|
-
effect(() => { expected = desiredTitle(); lockTitle(); });
|
|
32
|
-
render(html`<${App} />`, document.getElementById('app'));
|
|
33
|
-
|
|
34
|
-
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
35
|
-
// manifest meets install criteria (served over localhost / https, has icon,
|
|
36
|
-
// not already installed). We stash the event so the About page can offer
|
|
37
|
-
// a one-click install button that triggers it.
|
|
38
|
-
window.addEventListener('beforeinstallprompt', (ev) => {
|
|
39
|
-
ev.preventDefault();
|
|
40
|
-
installPrompt.value = ev;
|
|
41
|
-
});
|
|
42
|
-
window.addEventListener('appinstalled', () => {
|
|
43
|
-
installPrompt.value = null;
|
|
44
|
-
isInstalledPwa.value = true;
|
|
45
|
-
});
|
|
46
|
-
// On boot, detect if we're already running as an installed PWA window
|
|
47
|
-
// (display-mode standalone covers both plain PWA + WCO). When true, the
|
|
48
|
-
// "install" affordance hides itself.
|
|
49
|
-
const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
|
|
50
|
-
isInstalledPwa.value = mq.matches;
|
|
51
|
-
mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
|
|
52
|
-
|
|
53
|
-
// "is-app" body class · everything that isn't a regular browser tab
|
|
54
|
-
// (display-mode: browser) gets it. Used by wco.css to gate user-select
|
|
55
|
-
// on drag regions so chromeless --app= windows can be dragged by
|
|
56
|
-
// clicking the page title, while normal tabs still allow text select.
|
|
57
|
-
function applyIsAppClass() {
|
|
58
|
-
const isApp = !matchMedia('(display-mode: browser)').matches;
|
|
59
|
-
document.body.classList.toggle('is-app', isApp);
|
|
60
|
-
}
|
|
61
|
-
applyIsAppClass();
|
|
62
|
-
matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
|
|
63
|
-
|
|
64
|
-
// Force-collapse the sidebar on narrow viewports. Mirrors the responsive
|
|
65
|
-
// CSS so JS state (toggle visibility, tree-render gating) agrees with the
|
|
66
|
-
// rendered layout.
|
|
67
|
-
const narrowMq = matchMedia('(max-width: 900px)');
|
|
68
|
-
function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
|
|
69
|
-
applyNarrow();
|
|
70
|
-
narrowMq.addEventListener('change', applyNarrow);
|
|
71
|
-
|
|
72
|
-
(async () => {
|
|
73
|
-
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
74
|
-
// upgraded since this per-version frontend was loaded, bounce back to
|
|
75
|
-
// the router immediately — no point loading config from a server that
|
|
76
|
-
// speaks a different API revision. Runs in dev too (it no-ops without
|
|
77
|
-
// the build-time <meta>).
|
|
78
|
-
await bootVersionGuard();
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
await loadConfig();
|
|
82
|
-
await refreshAll();
|
|
83
|
-
pollHealth();
|
|
84
|
-
} catch (e) {
|
|
85
|
-
setToast('initial load failed · ' + e.message, 'error');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
|
|
89
|
-
// labels naturally track the data refresh; bumping clockTick more
|
|
90
|
-
// frequently would just cause needless re-renders since fmtAgo's
|
|
91
|
-
// resolution is coarse — 5s buckets under a minute, then m/h/d).
|
|
92
|
-
// loadWorkspaces is included because the workspace "in use" flag is
|
|
93
|
-
// derived from live session cwds server-side — without it, sessions
|
|
94
|
-
// move in/out of a workspace silently and the grid stays stale.
|
|
95
|
-
setInterval(async () => {
|
|
96
|
-
try {
|
|
97
|
-
await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
|
|
98
|
-
lastRefreshAt.value = Date.now();
|
|
99
|
-
} catch { /* swallow — next tick retries */ }
|
|
100
|
-
pollHealth();
|
|
101
|
-
clockTick.value = Date.now();
|
|
102
|
-
}, 5000);
|
|
103
|
-
|
|
104
|
-
// Heartbeat · the server uses this to (a) decide whether to shut down
|
|
105
|
-
// when its own spawned browser closes (multi-client check), and (b) as
|
|
106
|
-
// a 90s watchdog backup if the browser-exit signal is missed entirely.
|
|
107
|
-
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
108
|
-
// caught by the post-close decision in server.js; long enough not to be
|
|
109
|
-
// chatty.
|
|
110
|
-
const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
|
|
111
|
-
ping();
|
|
112
|
-
setInterval(ping, 10_000);
|
|
113
|
-
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|
|
114
|
-
})();
|
|
115
|
-
|
|
116
|
-
// ─── version routing guard ───────────────────────────────────────────
|
|
117
|
-
// Each deployed frontend is pinned to one backend version. The GH-Pages
|
|
118
|
-
// workflow bakes the version into <meta name="ccsm-frontend-version">
|
|
119
|
-
// so we can detect "backend has been upgraded since this frontend was
|
|
120
|
-
// loaded" and bounce back through the router at /ccsm/ for a fresh
|
|
121
|
-
// match. In dev (no meta tag, same-origin served-by-backend), the check
|
|
122
|
-
// no-ops — we're always running the frontend that ships with this
|
|
123
|
-
// backend by definition.
|
|
124
|
-
async function bootVersionGuard() {
|
|
125
|
-
const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
|
|
126
|
-
if (!meta) return; // dev mode
|
|
127
|
-
const myVer = meta.getAttribute('content');
|
|
128
|
-
if (!myVer) return;
|
|
129
|
-
let backendVer = null;
|
|
130
|
-
try {
|
|
131
|
-
const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
|
|
132
|
-
if (!r.ok) return;
|
|
133
|
-
backendVer = (await r.json()).version;
|
|
134
|
-
} catch { return; } // offline → OfflineBanner takes over
|
|
135
|
-
if (!backendVer || backendVer === myVer) return;
|
|
136
|
-
// Mismatch. Bounce up one level to the router. The router will
|
|
137
|
-
// probe /api/health again and redirect to ./<backendVer>/.
|
|
138
|
-
console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
|
|
139
|
-
location.replace('../');
|
|
140
|
-
}
|
|
1
|
+
// Entry. Loads persisted ui state → boots data → mounts App → spins up
|
|
2
|
+
// the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
|
|
3
|
+
// the mount root.
|
|
4
|
+
|
|
5
|
+
import { render } from 'preact';
|
|
6
|
+
import { effect } from '@preact/signals';
|
|
7
|
+
import { html } from './html.js';
|
|
8
|
+
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, serverHealth } from './state.js';
|
|
9
|
+
import { httpBase } from './backend.js';
|
|
10
|
+
import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
|
|
11
|
+
import { setToast } from './toast.js';
|
|
12
|
+
import { App } from './components/App.js';
|
|
13
|
+
|
|
14
|
+
loadPersisted();
|
|
15
|
+
// Window/tab title tracks the live backend version — "CCSM v0.10.1" once
|
|
16
|
+
// /api/health responds, "CCSM" before then. A MutationObserver guards
|
|
17
|
+
// against Chromium standalone builds that occasionally try to inject the
|
|
18
|
+
// URL into the title bar; it accepts any "CCSM..." string we set and
|
|
19
|
+
// resets anything else.
|
|
20
|
+
function desiredTitle() {
|
|
21
|
+
const v = serverHealth.value.version;
|
|
22
|
+
return v ? `CCSM v${v}` : 'CCSM';
|
|
23
|
+
}
|
|
24
|
+
let expected = desiredTitle();
|
|
25
|
+
function lockTitle() { if (document.title !== expected) document.title = expected; }
|
|
26
|
+
lockTitle();
|
|
27
|
+
new MutationObserver(lockTitle).observe(
|
|
28
|
+
document.querySelector('title') || document.head,
|
|
29
|
+
{ childList: true, subtree: true, characterData: true }
|
|
30
|
+
);
|
|
31
|
+
effect(() => { expected = desiredTitle(); lockTitle(); });
|
|
32
|
+
render(html`<${App} />`, document.getElementById('app'));
|
|
33
|
+
|
|
34
|
+
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
35
|
+
// manifest meets install criteria (served over localhost / https, has icon,
|
|
36
|
+
// not already installed). We stash the event so the About page can offer
|
|
37
|
+
// a one-click install button that triggers it.
|
|
38
|
+
window.addEventListener('beforeinstallprompt', (ev) => {
|
|
39
|
+
ev.preventDefault();
|
|
40
|
+
installPrompt.value = ev;
|
|
41
|
+
});
|
|
42
|
+
window.addEventListener('appinstalled', () => {
|
|
43
|
+
installPrompt.value = null;
|
|
44
|
+
isInstalledPwa.value = true;
|
|
45
|
+
});
|
|
46
|
+
// On boot, detect if we're already running as an installed PWA window
|
|
47
|
+
// (display-mode standalone covers both plain PWA + WCO). When true, the
|
|
48
|
+
// "install" affordance hides itself.
|
|
49
|
+
const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
|
|
50
|
+
isInstalledPwa.value = mq.matches;
|
|
51
|
+
mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
|
|
52
|
+
|
|
53
|
+
// "is-app" body class · everything that isn't a regular browser tab
|
|
54
|
+
// (display-mode: browser) gets it. Used by wco.css to gate user-select
|
|
55
|
+
// on drag regions so chromeless --app= windows can be dragged by
|
|
56
|
+
// clicking the page title, while normal tabs still allow text select.
|
|
57
|
+
function applyIsAppClass() {
|
|
58
|
+
const isApp = !matchMedia('(display-mode: browser)').matches;
|
|
59
|
+
document.body.classList.toggle('is-app', isApp);
|
|
60
|
+
}
|
|
61
|
+
applyIsAppClass();
|
|
62
|
+
matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
|
|
63
|
+
|
|
64
|
+
// Force-collapse the sidebar on narrow viewports. Mirrors the responsive
|
|
65
|
+
// CSS so JS state (toggle visibility, tree-render gating) agrees with the
|
|
66
|
+
// rendered layout.
|
|
67
|
+
const narrowMq = matchMedia('(max-width: 900px)');
|
|
68
|
+
function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
|
|
69
|
+
applyNarrow();
|
|
70
|
+
narrowMq.addEventListener('change', applyNarrow);
|
|
71
|
+
|
|
72
|
+
(async () => {
|
|
73
|
+
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
74
|
+
// upgraded since this per-version frontend was loaded, bounce back to
|
|
75
|
+
// the router immediately — no point loading config from a server that
|
|
76
|
+
// speaks a different API revision. Runs in dev too (it no-ops without
|
|
77
|
+
// the build-time <meta>).
|
|
78
|
+
await bootVersionGuard();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await loadConfig();
|
|
82
|
+
await refreshAll();
|
|
83
|
+
pollHealth();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
setToast('initial load failed · ' + e.message, 'error');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
|
|
89
|
+
// labels naturally track the data refresh; bumping clockTick more
|
|
90
|
+
// frequently would just cause needless re-renders since fmtAgo's
|
|
91
|
+
// resolution is coarse — 5s buckets under a minute, then m/h/d).
|
|
92
|
+
// loadWorkspaces is included because the workspace "in use" flag is
|
|
93
|
+
// derived from live session cwds server-side — without it, sessions
|
|
94
|
+
// move in/out of a workspace silently and the grid stays stale.
|
|
95
|
+
setInterval(async () => {
|
|
96
|
+
try {
|
|
97
|
+
await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
|
|
98
|
+
lastRefreshAt.value = Date.now();
|
|
99
|
+
} catch { /* swallow — next tick retries */ }
|
|
100
|
+
pollHealth();
|
|
101
|
+
clockTick.value = Date.now();
|
|
102
|
+
}, 5000);
|
|
103
|
+
|
|
104
|
+
// Heartbeat · the server uses this to (a) decide whether to shut down
|
|
105
|
+
// when its own spawned browser closes (multi-client check), and (b) as
|
|
106
|
+
// a 90s watchdog backup if the browser-exit signal is missed entirely.
|
|
107
|
+
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
108
|
+
// caught by the post-close decision in server.js; long enough not to be
|
|
109
|
+
// chatty.
|
|
110
|
+
const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
|
|
111
|
+
ping();
|
|
112
|
+
setInterval(ping, 10_000);
|
|
113
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
// ─── version routing guard ───────────────────────────────────────────
|
|
117
|
+
// Each deployed frontend is pinned to one backend version. The GH-Pages
|
|
118
|
+
// workflow bakes the version into <meta name="ccsm-frontend-version">
|
|
119
|
+
// so we can detect "backend has been upgraded since this frontend was
|
|
120
|
+
// loaded" and bounce back through the router at /ccsm/ for a fresh
|
|
121
|
+
// match. In dev (no meta tag, same-origin served-by-backend), the check
|
|
122
|
+
// no-ops — we're always running the frontend that ships with this
|
|
123
|
+
// backend by definition.
|
|
124
|
+
async function bootVersionGuard() {
|
|
125
|
+
const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
|
|
126
|
+
if (!meta) return; // dev mode
|
|
127
|
+
const myVer = meta.getAttribute('content');
|
|
128
|
+
if (!myVer) return;
|
|
129
|
+
let backendVer = null;
|
|
130
|
+
try {
|
|
131
|
+
const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
|
|
132
|
+
if (!r.ok) return;
|
|
133
|
+
backendVer = (await r.json()).version;
|
|
134
|
+
} catch { return; } // offline → OfflineBanner takes over
|
|
135
|
+
if (!backendVer || backendVer === myVer) return;
|
|
136
|
+
// Mismatch. Bounce up one level to the router. The router will
|
|
137
|
+
// probe /api/health again and redirect to ./<backendVer>/.
|
|
138
|
+
console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
|
|
139
|
+
location.replace('../');
|
|
140
|
+
}
|
|
@@ -1,165 +1,165 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
-
import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
|
|
4
|
-
import { setToast } from '../toast.js';
|
|
5
|
-
import { api } from '../api.js';
|
|
6
|
-
import { Card } from '../components/Card.js';
|
|
7
|
-
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
8
|
-
import { BrandMark, IconGithub, IconExternal } from '../icons.js';
|
|
9
|
-
|
|
10
|
-
const REPO_URL = 'https://github.com/bakapiano/ccsm';
|
|
11
|
-
const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
|
|
12
|
-
|
|
13
|
-
async function onInstall() {
|
|
14
|
-
const ev = installPrompt.value;
|
|
15
|
-
if (!ev) return setToast('install prompt not available right now · try opening this URL in a regular Edge tab', 'error');
|
|
16
|
-
ev.prompt();
|
|
17
|
-
const { outcome } = await ev.userChoice;
|
|
18
|
-
installPrompt.value = null;
|
|
19
|
-
if (outcome === 'accepted') {
|
|
20
|
-
setToast('installed · close + relaunch via npx ccsm to enable Window Controls Overlay');
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function InstallCard() {
|
|
25
|
-
if (isInstalledPwa.value) return null;
|
|
26
|
-
const canPrompt = !!installPrompt.value;
|
|
27
|
-
return html`
|
|
28
|
-
<${Card} title="Install as app">
|
|
29
|
-
<p class="about-copy" style="margin-bottom: var(--s-3);">
|
|
30
|
-
ccsm runs best as a Chromium PWA — title bar collapses into the page (Window Controls Overlay),
|
|
31
|
-
and the launch shortcut becomes a standalone app. One-click install on supported browsers below.
|
|
32
|
-
</p>
|
|
33
|
-
<div class="about-links">
|
|
34
|
-
<button class="action ${canPrompt ? 'primary' : 'subtle'}" onClick=${onInstall} disabled=${!canPrompt}>
|
|
35
|
-
${canPrompt ? 'Install ccsm' : 'Install not available'}
|
|
36
|
-
</button>
|
|
37
|
-
</div>
|
|
38
|
-
${!canPrompt ? html`
|
|
39
|
-
<p class="muted-text" style="margin-top: var(--s-3);">
|
|
40
|
-
If the button stays disabled: open <code>http://localhost:7777</code> in a regular Edge tab,
|
|
41
|
-
click the address-bar install icon (⊕), then re-launch via <code>npx ccsm</code>.
|
|
42
|
-
</p>` : null}
|
|
43
|
-
</${Card}>`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function UpgradeCard() {
|
|
47
|
-
const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
|
|
48
|
-
const [checking, setChecking] = useState(true);
|
|
49
|
-
const [upgrading, setUpgrading] = useState(false);
|
|
50
|
-
|
|
51
|
-
const refresh = async (force = false) => {
|
|
52
|
-
setChecking(true);
|
|
53
|
-
try {
|
|
54
|
-
const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
|
|
55
|
-
setInfo(r);
|
|
56
|
-
} catch (e) {
|
|
57
|
-
setInfo({ error: e.message });
|
|
58
|
-
} finally {
|
|
59
|
-
setChecking(false);
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
useEffect(() => { refresh(false); }, []);
|
|
63
|
-
|
|
64
|
-
const onUpgrade = async () => {
|
|
65
|
-
if (!info?.updateAvailable) return;
|
|
66
|
-
setUpgrading(true);
|
|
67
|
-
try {
|
|
68
|
-
await api('POST', '/api/upgrade', { target: 'latest' });
|
|
69
|
-
setToast(`upgrading to v${info.latest} · backend will restart`);
|
|
70
|
-
} catch (e) {
|
|
71
|
-
setUpgrading(false);
|
|
72
|
-
setToast(e.message, 'error');
|
|
73
|
-
}
|
|
74
|
-
// No "finally" reset — the server is about to shut down, and the
|
|
75
|
-
// OfflineBanner takes over UI. When the router reroutes us to the new
|
|
76
|
-
// version's frontend, this component re-mounts fresh.
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const current = info?.current || serverHealth.value.version || '';
|
|
80
|
-
const latest = info?.latest;
|
|
81
|
-
const updateAvailable = !!info?.updateAvailable;
|
|
82
|
-
|
|
83
|
-
return html`
|
|
84
|
-
<${Card} title="Version">
|
|
85
|
-
<div class="about-version-row">
|
|
86
|
-
<div>
|
|
87
|
-
<div class="about-version-line">
|
|
88
|
-
Installed · <span class="mono">v${current || '?'}</span>
|
|
89
|
-
</div>
|
|
90
|
-
${latest && !updateAvailable ? html`
|
|
91
|
-
<div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
|
|
92
|
-
` : null}
|
|
93
|
-
${updateAvailable ? html`
|
|
94
|
-
<div class="about-update-line">
|
|
95
|
-
Update available · <span class="mono">v${latest}</span>
|
|
96
|
-
</div>
|
|
97
|
-
` : null}
|
|
98
|
-
${info?.error ? html`
|
|
99
|
-
<div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
|
|
100
|
-
` : null}
|
|
101
|
-
</div>
|
|
102
|
-
<div class="about-version-actions">
|
|
103
|
-
<button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
|
|
104
|
-
${checking ? 'Checking…' : 'Check'}
|
|
105
|
-
</button>
|
|
106
|
-
${updateAvailable ? html`
|
|
107
|
-
<button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
|
|
108
|
-
${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
|
|
109
|
-
</button>
|
|
110
|
-
` : null}
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
${upgrading ? html`
|
|
114
|
-
<p class="muted-text" style="margin-top:var(--s-3)">
|
|
115
|
-
Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
|
|
116
|
-
</p>
|
|
117
|
-
` : null}
|
|
118
|
-
</${Card}>`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function AboutPage() {
|
|
122
|
-
const version = serverHealth.value.version;
|
|
123
|
-
|
|
124
|
-
return html`
|
|
125
|
-
<${PageTitleBar} title="About" />
|
|
126
|
-
<${UpgradeCard} />
|
|
127
|
-
<${InstallCard} />
|
|
128
|
-
<${Card} title="ccsm">
|
|
129
|
-
<div class="about-block">
|
|
130
|
-
<div class="about-hero">
|
|
131
|
-
<span class="about-mark"><${BrandMark} /></span>
|
|
132
|
-
<div>
|
|
133
|
-
<div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
|
|
134
|
-
<div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
|
|
138
|
-
<p class="about-copy">
|
|
139
|
-
Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
|
|
140
|
-
and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
|
|
141
|
-
ad-hoc repo clones.
|
|
142
|
-
</p>
|
|
143
|
-
|
|
144
|
-
<div class="about-links">
|
|
145
|
-
<a class="action" href=${REPO_URL} target="_blank" rel="noopener">
|
|
146
|
-
<${IconGithub} /> GitHub <${IconExternal} />
|
|
147
|
-
</a>
|
|
148
|
-
<a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
|
|
149
|
-
npm <${IconExternal} />
|
|
150
|
-
</a>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
<dl class="about-meta">
|
|
154
|
-
<dt>Install</dt>
|
|
155
|
-
<dd><code>npx @bakapiano/ccsm</code></dd>
|
|
156
|
-
<dt>Data directory</dt>
|
|
157
|
-
<dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
|
|
158
|
-
<dt>Platform</dt>
|
|
159
|
-
<dd>Windows · Node 18+</dd>
|
|
160
|
-
<dt>License</dt>
|
|
161
|
-
<dd>MIT</dd>
|
|
162
|
-
</dl>
|
|
163
|
-
</div>
|
|
164
|
-
</${Card}>`;
|
|
165
|
-
}
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
|
|
4
|
+
import { setToast } from '../toast.js';
|
|
5
|
+
import { api } from '../api.js';
|
|
6
|
+
import { Card } from '../components/Card.js';
|
|
7
|
+
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
8
|
+
import { BrandMark, IconGithub, IconExternal } from '../icons.js';
|
|
9
|
+
|
|
10
|
+
const REPO_URL = 'https://github.com/bakapiano/ccsm';
|
|
11
|
+
const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
|
|
12
|
+
|
|
13
|
+
async function onInstall() {
|
|
14
|
+
const ev = installPrompt.value;
|
|
15
|
+
if (!ev) return setToast('install prompt not available right now · try opening this URL in a regular Edge tab', 'error');
|
|
16
|
+
ev.prompt();
|
|
17
|
+
const { outcome } = await ev.userChoice;
|
|
18
|
+
installPrompt.value = null;
|
|
19
|
+
if (outcome === 'accepted') {
|
|
20
|
+
setToast('installed · close + relaunch via npx ccsm to enable Window Controls Overlay');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function InstallCard() {
|
|
25
|
+
if (isInstalledPwa.value) return null;
|
|
26
|
+
const canPrompt = !!installPrompt.value;
|
|
27
|
+
return html`
|
|
28
|
+
<${Card} title="Install as app">
|
|
29
|
+
<p class="about-copy" style="margin-bottom: var(--s-3);">
|
|
30
|
+
ccsm runs best as a Chromium PWA — title bar collapses into the page (Window Controls Overlay),
|
|
31
|
+
and the launch shortcut becomes a standalone app. One-click install on supported browsers below.
|
|
32
|
+
</p>
|
|
33
|
+
<div class="about-links">
|
|
34
|
+
<button class="action ${canPrompt ? 'primary' : 'subtle'}" onClick=${onInstall} disabled=${!canPrompt}>
|
|
35
|
+
${canPrompt ? 'Install ccsm' : 'Install not available'}
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
${!canPrompt ? html`
|
|
39
|
+
<p class="muted-text" style="margin-top: var(--s-3);">
|
|
40
|
+
If the button stays disabled: open <code>http://localhost:7777</code> in a regular Edge tab,
|
|
41
|
+
click the address-bar install icon (⊕), then re-launch via <code>npx ccsm</code>.
|
|
42
|
+
</p>` : null}
|
|
43
|
+
</${Card}>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function UpgradeCard() {
|
|
47
|
+
const [info, setInfo] = useState(null); // { current, latest, updateAvailable, fetchedAt, error? }
|
|
48
|
+
const [checking, setChecking] = useState(true);
|
|
49
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
50
|
+
|
|
51
|
+
const refresh = async (force = false) => {
|
|
52
|
+
setChecking(true);
|
|
53
|
+
try {
|
|
54
|
+
const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
|
|
55
|
+
setInfo(r);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setInfo({ error: e.message });
|
|
58
|
+
} finally {
|
|
59
|
+
setChecking(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
useEffect(() => { refresh(false); }, []);
|
|
63
|
+
|
|
64
|
+
const onUpgrade = async () => {
|
|
65
|
+
if (!info?.updateAvailable) return;
|
|
66
|
+
setUpgrading(true);
|
|
67
|
+
try {
|
|
68
|
+
await api('POST', '/api/upgrade', { target: 'latest' });
|
|
69
|
+
setToast(`upgrading to v${info.latest} · backend will restart`);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
setUpgrading(false);
|
|
72
|
+
setToast(e.message, 'error');
|
|
73
|
+
}
|
|
74
|
+
// No "finally" reset — the server is about to shut down, and the
|
|
75
|
+
// OfflineBanner takes over UI. When the router reroutes us to the new
|
|
76
|
+
// version's frontend, this component re-mounts fresh.
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const current = info?.current || serverHealth.value.version || '';
|
|
80
|
+
const latest = info?.latest;
|
|
81
|
+
const updateAvailable = !!info?.updateAvailable;
|
|
82
|
+
|
|
83
|
+
return html`
|
|
84
|
+
<${Card} title="Version">
|
|
85
|
+
<div class="about-version-row">
|
|
86
|
+
<div>
|
|
87
|
+
<div class="about-version-line">
|
|
88
|
+
Installed · <span class="mono">v${current || '?'}</span>
|
|
89
|
+
</div>
|
|
90
|
+
${latest && !updateAvailable ? html`
|
|
91
|
+
<div class="muted-text" style="margin-top:4px">You're on the latest release.</div>
|
|
92
|
+
` : null}
|
|
93
|
+
${updateAvailable ? html`
|
|
94
|
+
<div class="about-update-line">
|
|
95
|
+
Update available · <span class="mono">v${latest}</span>
|
|
96
|
+
</div>
|
|
97
|
+
` : null}
|
|
98
|
+
${info?.error ? html`
|
|
99
|
+
<div class="muted-text" style="margin-top:4px">Couldn't reach npm registry.</div>
|
|
100
|
+
` : null}
|
|
101
|
+
</div>
|
|
102
|
+
<div class="about-version-actions">
|
|
103
|
+
<button class="action subtle" onClick=${() => refresh(true)} disabled=${checking || upgrading}>
|
|
104
|
+
${checking ? 'Checking…' : 'Check'}
|
|
105
|
+
</button>
|
|
106
|
+
${updateAvailable ? html`
|
|
107
|
+
<button class="action primary" onClick=${onUpgrade} disabled=${upgrading}>
|
|
108
|
+
${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
|
|
109
|
+
</button>
|
|
110
|
+
` : null}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
${upgrading ? html`
|
|
114
|
+
<p class="muted-text" style="margin-top:var(--s-3)">
|
|
115
|
+
Running <code>npm i -g @bakapiano/ccsm@latest</code>. The backend will restart automatically — you'll see the "Backend not running" screen briefly.
|
|
116
|
+
</p>
|
|
117
|
+
` : null}
|
|
118
|
+
</${Card}>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function AboutPage() {
|
|
122
|
+
const version = serverHealth.value.version;
|
|
123
|
+
|
|
124
|
+
return html`
|
|
125
|
+
<${PageTitleBar} title="About" />
|
|
126
|
+
<${UpgradeCard} />
|
|
127
|
+
<${InstallCard} />
|
|
128
|
+
<${Card} title="ccsm">
|
|
129
|
+
<div class="about-block">
|
|
130
|
+
<div class="about-hero">
|
|
131
|
+
<span class="about-mark"><${BrandMark} /></span>
|
|
132
|
+
<div>
|
|
133
|
+
<div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
|
|
134
|
+
<div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<p class="about-copy">
|
|
139
|
+
Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
|
|
140
|
+
and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
|
|
141
|
+
ad-hoc repo clones.
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<div class="about-links">
|
|
145
|
+
<a class="action" href=${REPO_URL} target="_blank" rel="noopener">
|
|
146
|
+
<${IconGithub} /> GitHub <${IconExternal} />
|
|
147
|
+
</a>
|
|
148
|
+
<a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
|
|
149
|
+
npm <${IconExternal} />
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<dl class="about-meta">
|
|
154
|
+
<dt>Install</dt>
|
|
155
|
+
<dd><code>npx @bakapiano/ccsm</code></dd>
|
|
156
|
+
<dt>Data directory</dt>
|
|
157
|
+
<dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
|
|
158
|
+
<dt>Platform</dt>
|
|
159
|
+
<dd>Windows · Node 18+</dd>
|
|
160
|
+
<dt>License</dt>
|
|
161
|
+
<dd>MIT</dd>
|
|
162
|
+
</dl>
|
|
163
|
+
</div>
|
|
164
|
+
</${Card}>`;
|
|
165
|
+
}
|