@bakapiano/ccsm 0.6.0 → 0.8.3
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/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/favorites.js +23 -45
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +21 -41
- package/lib/webTerminal.js +173 -0
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +1 -1
- package/public/index.html +52 -490
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +111 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +286 -30
- package/public/app.js +0 -1353
- package/public/styles.css +0 -1639
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Promise-based confirm/prompt rendered through DialogHost. The stack
|
|
2
|
+
// signal lets us nest dialogs if ever needed; .close() pops by id.
|
|
3
|
+
|
|
4
|
+
import { signal } from '@preact/signals';
|
|
5
|
+
import { html } from './html.js';
|
|
6
|
+
|
|
7
|
+
export const dialogs = signal([]);
|
|
8
|
+
let nextId = 1;
|
|
9
|
+
|
|
10
|
+
function push(entry) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const id = nextId++;
|
|
13
|
+
const close = (action, host) => {
|
|
14
|
+
dialogs.value = dialogs.value.filter((d) => d.id !== id);
|
|
15
|
+
resolve(entry.onResolve(action, host));
|
|
16
|
+
};
|
|
17
|
+
dialogs.value = [...dialogs.value, { id, ...entry, close }];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ccsmConfirm(message, opts = {}) {
|
|
22
|
+
const { title = 'Confirm', okLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = opts;
|
|
23
|
+
return push({
|
|
24
|
+
render: () => html`<div class="modal modal-dialog">
|
|
25
|
+
<header class="modal-head"><h2>${title}</h2></header>
|
|
26
|
+
<div class="modal-body"><p class="dialog-msg">${message}</p></div>
|
|
27
|
+
<footer class="modal-foot">
|
|
28
|
+
<button class="action" data-action="cancel">${cancelLabel}</button>
|
|
29
|
+
<button class=${`action ${danger ? 'danger' : 'primary'}`} data-action="ok">${okLabel}</button>
|
|
30
|
+
</footer>
|
|
31
|
+
</div>`,
|
|
32
|
+
onResolve: (action) => action === 'ok' || action === 'enter',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ccsmPrompt(message, defaultValue = '', opts = {}) {
|
|
37
|
+
const { title, okLabel = 'Save', cancelLabel = 'Cancel', placeholder = '' } = opts;
|
|
38
|
+
return push({
|
|
39
|
+
render: () => html`<div class="modal modal-dialog">
|
|
40
|
+
<header class="modal-head"><h2>${title || message}</h2></header>
|
|
41
|
+
<div class="modal-body">
|
|
42
|
+
${title ? html`<p class="dialog-msg">${message}</p>` : null}
|
|
43
|
+
<input type="text" class="input" placeholder=${placeholder} value=${defaultValue} />
|
|
44
|
+
</div>
|
|
45
|
+
<footer class="modal-foot">
|
|
46
|
+
<button class="action" data-action="cancel">${cancelLabel}</button>
|
|
47
|
+
<button class="action primary" data-action="ok">${okLabel}</button>
|
|
48
|
+
</footer>
|
|
49
|
+
</div>`,
|
|
50
|
+
initialFocus: (host) => {
|
|
51
|
+
const inp = host.querySelector('input');
|
|
52
|
+
if (inp) { inp.focus(); inp.select(); }
|
|
53
|
+
},
|
|
54
|
+
onResolve: (action, host) => {
|
|
55
|
+
const inp = host?.querySelector('input');
|
|
56
|
+
return (action === 'ok' || action === 'enter') ? (inp?.value ?? '') : null;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SVG icon components. Each accepts {size, className} but most callers
|
|
2
|
+
// just take the default — sizing happens via CSS.
|
|
3
|
+
|
|
4
|
+
import { html } from './html.js';
|
|
5
|
+
|
|
6
|
+
const ic = (vb, body, defaultSize = 16) => ({ size = defaultSize, className = '', stroke = 1.6, fill = 'none' } = {}) =>
|
|
7
|
+
html`<svg viewBox=${vb} width=${size} height=${size} fill=${fill} stroke="currentColor"
|
|
8
|
+
stroke-width=${stroke} stroke-linecap="round" stroke-linejoin="round" class=${className} aria-hidden="true">${body}</svg>`;
|
|
9
|
+
|
|
10
|
+
export const IconSessions = ic('0 0 24 24', html`
|
|
11
|
+
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
12
|
+
<line x1="3" y1="12" x2="21" y2="12"/>
|
|
13
|
+
<line x1="3" y1="18" x2="14" y2="18"/>
|
|
14
|
+
`, 18);
|
|
15
|
+
|
|
16
|
+
export const IconLaunch = ic('0 0 24 24', html`
|
|
17
|
+
<path d="M7 17L17 7"/><path d="M9 7h8v8"/>
|
|
18
|
+
`, 18);
|
|
19
|
+
|
|
20
|
+
export const IconConfigure = ic('0 0 24 24', html`
|
|
21
|
+
<circle cx="12" cy="12" r="3"/>
|
|
22
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
23
|
+
`, 18);
|
|
24
|
+
|
|
25
|
+
export const IconRefresh = ic('0 0 24 24', html`
|
|
26
|
+
<polyline points="23 4 23 10 17 10"/>
|
|
27
|
+
<polyline points="1 20 1 14 7 14"/>
|
|
28
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
29
|
+
`, 16);
|
|
30
|
+
|
|
31
|
+
export const IconChevronLeft = ic('0 0 24 24', html`<polyline points="15 18 9 12 15 6"/>`, 14);
|
|
32
|
+
export const IconChevronDown = ic('0 0 24 24', html`<polyline points="6 9 12 15 18 9"/>`, 14);
|
|
33
|
+
|
|
34
|
+
export const IconSearch = ic('0 0 24 24', html`
|
|
35
|
+
<circle cx="11" cy="11" r="7"/>
|
|
36
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
37
|
+
`, 14);
|
|
38
|
+
|
|
39
|
+
export const IconClose = ic('0 0 24 24', html`
|
|
40
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
41
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
42
|
+
`, 18);
|
|
43
|
+
|
|
44
|
+
export const IconPlus = ic('0 0 24 24', html`
|
|
45
|
+
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
46
|
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
47
|
+
`, 22);
|
|
48
|
+
|
|
49
|
+
export const IconPencil = ic('0 0 24 24', html`
|
|
50
|
+
<path d="M12 20h9"/>
|
|
51
|
+
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
52
|
+
`, 13);
|
|
53
|
+
|
|
54
|
+
export const IconInfo = ic('0 0 24 24', html`
|
|
55
|
+
<circle cx="12" cy="12" r="10"/>
|
|
56
|
+
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
57
|
+
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
58
|
+
`, 18);
|
|
59
|
+
|
|
60
|
+
export const IconGithub = ic('0 0 24 24', html`
|
|
61
|
+
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
|
|
62
|
+
`, 16);
|
|
63
|
+
|
|
64
|
+
export const IconExternal = ic('0 0 24 24', html`
|
|
65
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
66
|
+
<polyline points="15 3 21 3 21 9"/>
|
|
67
|
+
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
68
|
+
`, 13);
|
|
69
|
+
|
|
70
|
+
// Monitor outline — used on "Focus" buttons since the action raises an
|
|
71
|
+
// existing terminal window. Reads as "go to that window".
|
|
72
|
+
export const IconMonitor = ic('0 0 24 24', html`
|
|
73
|
+
<rect x="3" y="4" width="18" height="12" rx="2" ry="2"/>
|
|
74
|
+
<line x1="8" y1="20" x2="16" y2="20"/>
|
|
75
|
+
<line x1="12" y1="16" x2="12" y2="20"/>
|
|
76
|
+
`, 13);
|
|
77
|
+
|
|
78
|
+
// "> _" terminal prompt — for the Terminals nav tab
|
|
79
|
+
export const IconTerminal = ic('0 0 24 24', html`
|
|
80
|
+
<polyline points="4 17 10 11 4 5"/>
|
|
81
|
+
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
82
|
+
`, 18);
|
|
83
|
+
|
|
84
|
+
// Two variants used in the StarButton.
|
|
85
|
+
export const StarOutline = ({ size = 15 } = {}) => html`
|
|
86
|
+
<svg viewBox="0 0 24 24" width=${size} height=${size} fill="none" stroke="currentColor"
|
|
87
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
88
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
89
|
+
</svg>`;
|
|
90
|
+
|
|
91
|
+
export const StarFilled = ({ size = 15 } = {}) => html`
|
|
92
|
+
<svg viewBox="0 0 24 24" width=${size} height=${size} fill="currentColor" stroke="currentColor"
|
|
93
|
+
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
94
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
95
|
+
</svg>`;
|
|
96
|
+
|
|
97
|
+
// Used as the favorites card title decoration
|
|
98
|
+
export const StarSmallFilled = ({ size = 14 } = {}) => html`
|
|
99
|
+
<svg class="title-icon title-icon-after" viewBox="0 0 24 24" width=${size} height=${size} fill="currentColor" stroke="none" aria-hidden="true">
|
|
100
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
101
|
+
</svg>`;
|
|
102
|
+
|
|
103
|
+
// brand mark (terminal window + ccsm text — matches /favicon.svg)
|
|
104
|
+
export const BrandMark = () => html`
|
|
105
|
+
<svg viewBox="0 0 32 32" width="32" height="32">
|
|
106
|
+
<rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
|
|
107
|
+
<line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
|
|
108
|
+
<circle cx="6" cy="7" r="1" fill="#faf9f5"/>
|
|
109
|
+
<circle cx="9.5" cy="7" r="1" fill="#faf9f5" opacity="0.65"/>
|
|
110
|
+
<circle cx="13" cy="7" r="1" fill="#faf9f5" opacity="0.4"/>
|
|
111
|
+
<text x="16" y="19.5" text-anchor="middle" dominant-baseline="central"
|
|
112
|
+
font-family="'JetBrains Mono', 'Cascadia Mono', 'Consolas', monospace"
|
|
113
|
+
font-weight="700" font-size="10" fill="#faf9f5">ccsm</text>
|
|
114
|
+
</svg>`;
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { html } from './html.js';
|
|
7
|
+
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa } from './state.js';
|
|
8
|
+
import { httpBase } from './backend.js';
|
|
9
|
+
import { loadConfig, refreshAll, loadSessions, loadRecent, loadSnapshot, loadWorkspaces, loadWebTerminals, pollHealth } from './api.js';
|
|
10
|
+
import { setToast } from './toast.js';
|
|
11
|
+
import { App } from './components/App.js';
|
|
12
|
+
|
|
13
|
+
loadPersisted();
|
|
14
|
+
render(html`<${App} />`, document.getElementById('app'));
|
|
15
|
+
|
|
16
|
+
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
17
|
+
// manifest meets install criteria (served over localhost / https, has icon,
|
|
18
|
+
// not already installed). We stash the event so the About page can offer
|
|
19
|
+
// a one-click install button that triggers it.
|
|
20
|
+
window.addEventListener('beforeinstallprompt', (ev) => {
|
|
21
|
+
ev.preventDefault();
|
|
22
|
+
installPrompt.value = ev;
|
|
23
|
+
});
|
|
24
|
+
window.addEventListener('appinstalled', () => {
|
|
25
|
+
installPrompt.value = null;
|
|
26
|
+
isInstalledPwa.value = true;
|
|
27
|
+
});
|
|
28
|
+
// On boot, detect if we're already running as an installed PWA window
|
|
29
|
+
// (display-mode standalone covers both plain PWA + WCO). When true, the
|
|
30
|
+
// "install" affordance hides itself.
|
|
31
|
+
const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
|
|
32
|
+
isInstalledPwa.value = mq.matches;
|
|
33
|
+
mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
|
|
34
|
+
|
|
35
|
+
// "is-app" body class · everything that isn't a regular browser tab
|
|
36
|
+
// (display-mode: browser) gets it. Used by wco.css to gate user-select
|
|
37
|
+
// on drag regions so chromeless --app= windows can be dragged by
|
|
38
|
+
// clicking the page title, while normal tabs still allow text select.
|
|
39
|
+
function applyIsAppClass() {
|
|
40
|
+
const isApp = !matchMedia('(display-mode: browser)').matches;
|
|
41
|
+
document.body.classList.toggle('is-app', isApp);
|
|
42
|
+
}
|
|
43
|
+
applyIsAppClass();
|
|
44
|
+
matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
|
|
45
|
+
|
|
46
|
+
(async () => {
|
|
47
|
+
try {
|
|
48
|
+
await loadConfig();
|
|
49
|
+
await refreshAll();
|
|
50
|
+
pollHealth();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
setToast('initial load failed · ' + e.message, 'error');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
|
|
56
|
+
// labels naturally track the data refresh; bumping clockTick more
|
|
57
|
+
// frequently would just cause needless re-renders since fmtAgo's
|
|
58
|
+
// resolution is coarse — 5s buckets under a minute, then m/h/d).
|
|
59
|
+
// loadWorkspaces is included because the workspace "in use" flag is
|
|
60
|
+
// derived from live session cwds server-side — without it, sessions
|
|
61
|
+
// move in/out of a workspace silently and the grid stays stale.
|
|
62
|
+
setInterval(async () => {
|
|
63
|
+
try {
|
|
64
|
+
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadWebTerminals()]);
|
|
65
|
+
lastRefreshAt.value = Date.now();
|
|
66
|
+
} catch { /* swallow — next tick retries */ }
|
|
67
|
+
pollHealth();
|
|
68
|
+
clockTick.value = Date.now();
|
|
69
|
+
}, 5000);
|
|
70
|
+
|
|
71
|
+
// Heartbeat · the server uses this to (a) decide whether to shut down
|
|
72
|
+
// when its own spawned browser closes (multi-client check), and (b) as
|
|
73
|
+
// a 90s watchdog backup if the browser-exit signal is missed entirely.
|
|
74
|
+
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
75
|
+
// caught by the post-close decision in server.js; long enough not to be
|
|
76
|
+
// chatty.
|
|
77
|
+
const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
|
|
78
|
+
ping();
|
|
79
|
+
setInterval(ping, 10_000);
|
|
80
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|
|
81
|
+
})();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { serverHealth, installPrompt, isInstalledPwa } from '../state.js';
|
|
3
|
+
import { setToast } from '../toast.js';
|
|
4
|
+
import { Card } from '../components/Card.js';
|
|
5
|
+
import { BrandMark, IconGithub, IconExternal } from '../icons.js';
|
|
6
|
+
|
|
7
|
+
const REPO_URL = 'https://github.com/bakapiano/cssm';
|
|
8
|
+
const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
|
|
9
|
+
|
|
10
|
+
async function onInstall() {
|
|
11
|
+
const ev = installPrompt.value;
|
|
12
|
+
if (!ev) return setToast('install prompt not available right now · try opening this URL in a regular Edge tab', 'error');
|
|
13
|
+
ev.prompt();
|
|
14
|
+
const { outcome } = await ev.userChoice;
|
|
15
|
+
installPrompt.value = null;
|
|
16
|
+
if (outcome === 'accepted') {
|
|
17
|
+
setToast('installed · close + relaunch via npx ccsm to enable Window Controls Overlay');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function InstallCard() {
|
|
22
|
+
if (isInstalledPwa.value) return null;
|
|
23
|
+
const canPrompt = !!installPrompt.value;
|
|
24
|
+
return html`
|
|
25
|
+
<${Card} title="Install as app">
|
|
26
|
+
<p class="about-copy" style="margin-bottom: var(--s-3);">
|
|
27
|
+
ccsm runs best as a Chromium PWA — title bar collapses into the page (Window Controls Overlay),
|
|
28
|
+
and the launch shortcut becomes a standalone app. One-click install on supported browsers below.
|
|
29
|
+
</p>
|
|
30
|
+
<div class="about-links">
|
|
31
|
+
<button class="action ${canPrompt ? 'primary' : 'subtle'}" onClick=${onInstall} disabled=${!canPrompt}>
|
|
32
|
+
${canPrompt ? 'Install ccsm' : 'Install not available'}
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
${!canPrompt ? html`
|
|
36
|
+
<p class="muted-text" style="margin-top: var(--s-3);">
|
|
37
|
+
If the button stays disabled: open <code>http://localhost:7777</code> in a regular Edge tab,
|
|
38
|
+
click the address-bar install icon (⊕), then re-launch via <code>npx ccsm</code>.
|
|
39
|
+
</p>` : null}
|
|
40
|
+
</${Card}>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function AboutPage() {
|
|
44
|
+
const version = serverHealth.value.version;
|
|
45
|
+
|
|
46
|
+
return html`
|
|
47
|
+
<${InstallCard} />
|
|
48
|
+
<${Card} title="ccsm">
|
|
49
|
+
<div class="about-block">
|
|
50
|
+
<div class="about-hero">
|
|
51
|
+
<span class="about-mark"><${BrandMark} /></span>
|
|
52
|
+
<div>
|
|
53
|
+
<div class="about-name">ccsm <span class="about-version">${version ? `v${version}` : ''}</span></div>
|
|
54
|
+
<div class="about-tagline">Claude Code Session Manager · a single pane over every live <code>claude</code> session on this box.</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<p class="about-copy">
|
|
59
|
+
Lists live and recently-closed sessions, snapshots them every minute, restores them through Windows Terminal,
|
|
60
|
+
and launches fresh sessions inside isolated workspaces. Designed for running 8–10 concurrent sessions across
|
|
61
|
+
ad-hoc repo clones.
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<div class="about-links">
|
|
65
|
+
<a class="action" href=${REPO_URL} target="_blank" rel="noopener">
|
|
66
|
+
<${IconGithub} /> GitHub <${IconExternal} />
|
|
67
|
+
</a>
|
|
68
|
+
<a class="action subtle" href=${NPM_URL} target="_blank" rel="noopener">
|
|
69
|
+
npm <${IconExternal} />
|
|
70
|
+
</a>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<dl class="about-meta">
|
|
74
|
+
<dt>Install</dt>
|
|
75
|
+
<dd><code>npx @bakapiano/ccsm</code></dd>
|
|
76
|
+
<dt>Data directory</dt>
|
|
77
|
+
<dd><code>~/.ccsm/</code> (override with <code>CCSM_HOME</code>)</dd>
|
|
78
|
+
<dt>Platform</dt>
|
|
79
|
+
<dd>Windows · Node 18+</dd>
|
|
80
|
+
<dt>License</dt>
|
|
81
|
+
<dd>MIT</dd>
|
|
82
|
+
</dl>
|
|
83
|
+
</div>
|
|
84
|
+
</${Card}>`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Settings form. Edits to inputs mark configDirty until Save / Discard.
|
|
2
|
+
// We write to a draft signal to avoid clobbering server-side state mid-edit;
|
|
3
|
+
// the draft initialises from config.value the first time configure tab mounts
|
|
4
|
+
// and after each successful save.
|
|
5
|
+
|
|
6
|
+
import { html } from '../html.js';
|
|
7
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
8
|
+
import { config, terminals, configDirty } from '../state.js';
|
|
9
|
+
import { api, loadWorkspaces } from '../api.js';
|
|
10
|
+
import { setToast } from '../toast.js';
|
|
11
|
+
import { ccsmConfirm } from '../dialog.js';
|
|
12
|
+
import { Card } from '../components/Card.js';
|
|
13
|
+
import { ReposEditor, addEmptyRepo } from '../components/ReposEditor.js';
|
|
14
|
+
|
|
15
|
+
function defaultsFrom(cfg) {
|
|
16
|
+
if (!cfg) return null;
|
|
17
|
+
return {
|
|
18
|
+
port: cfg.port,
|
|
19
|
+
workDir: cfg.workDir,
|
|
20
|
+
snapshotIntervalMs: cfg.snapshotIntervalMs,
|
|
21
|
+
snapshotHistoryKeep: cfg.snapshotHistoryKeep,
|
|
22
|
+
claudeCommand: cfg.claudeCommand || 'claude',
|
|
23
|
+
terminal: cfg.terminal,
|
|
24
|
+
commandShell: cfg.commandShell || 'pwsh',
|
|
25
|
+
autoFocusOnLaunch: cfg.autoFocusOnLaunch !== false,
|
|
26
|
+
focusMovesToCenter: cfg.focusMovesToCenter === true,
|
|
27
|
+
browserMode: cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'),
|
|
28
|
+
finderPrompt: cfg.finderPrompt || '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ConfigurePage() {
|
|
33
|
+
const cfg = config.value;
|
|
34
|
+
const [draft, setDraft] = useState(() => defaultsFrom(cfg));
|
|
35
|
+
const [savedAt, setSavedAt] = useState('');
|
|
36
|
+
|
|
37
|
+
// re-init from config whenever a fresh load lands (rare — typically only at boot,
|
|
38
|
+
// after Save, or after Discard). We compare a stringified snapshot so re-renders
|
|
39
|
+
// from unrelated signals don't reset our in-progress edits.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!cfg) return;
|
|
42
|
+
if (!draft) { setDraft(defaultsFrom(cfg)); return; }
|
|
43
|
+
}, [cfg]);
|
|
44
|
+
|
|
45
|
+
if (!cfg || !draft) return null;
|
|
46
|
+
|
|
47
|
+
const update = (patch) => {
|
|
48
|
+
setDraft({ ...draft, ...patch });
|
|
49
|
+
configDirty.value = true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const reposChanged = () => {
|
|
53
|
+
configDirty.value = true;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const onSave = async () => {
|
|
57
|
+
const next = {
|
|
58
|
+
...draft,
|
|
59
|
+
port: Number(draft.port) || 7777,
|
|
60
|
+
snapshotIntervalMs: Math.max(5000, Number(draft.snapshotIntervalMs) || 60000),
|
|
61
|
+
snapshotHistoryKeep: Math.max(1, Number(draft.snapshotHistoryKeep) || 30),
|
|
62
|
+
claudeCommand: (draft.claudeCommand || 'claude').trim(),
|
|
63
|
+
terminal: draft.terminal || 'wt',
|
|
64
|
+
commandShell: draft.commandShell || 'pwsh',
|
|
65
|
+
browserMode: draft.browserMode || 'app',
|
|
66
|
+
workDir: (draft.workDir || '').trim(),
|
|
67
|
+
repos: (cfg.repos || []).filter((r) => r.name && r.url),
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const saved = await api('PUT', '/api/config', next);
|
|
71
|
+
config.value = saved;
|
|
72
|
+
setDraft(defaultsFrom(saved));
|
|
73
|
+
setSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
|
|
74
|
+
configDirty.value = false;
|
|
75
|
+
setToast('config saved');
|
|
76
|
+
await loadWorkspaces();
|
|
77
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onDiscard = async () => {
|
|
81
|
+
const ok = await ccsmConfirm('Discard your unsaved changes?', {
|
|
82
|
+
title: 'Discard changes', okLabel: 'Discard', danger: true,
|
|
83
|
+
});
|
|
84
|
+
if (!ok) return;
|
|
85
|
+
const fresh = await api('GET', '/api/config');
|
|
86
|
+
config.value = fresh;
|
|
87
|
+
setDraft(defaultsFrom(fresh));
|
|
88
|
+
configDirty.value = false;
|
|
89
|
+
setToast('changes discarded');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return html`
|
|
93
|
+
${configDirty.value ? html`
|
|
94
|
+
<div class="dirty-banner">
|
|
95
|
+
<span class="dirty-dot"></span>
|
|
96
|
+
<span class="dirty-text">You have unsaved changes</span>
|
|
97
|
+
<button class="action small primary" onClick=${onSave}>Save now</button>
|
|
98
|
+
<button class="action small subtle" onClick=${onDiscard}>Discard</button>
|
|
99
|
+
</div>` : null}
|
|
100
|
+
|
|
101
|
+
<${Card} title="Settings" meta=${html`Persisted to <code>~/.ccsm/config.json</code>`}>
|
|
102
|
+
<div class="config-grid">
|
|
103
|
+
<label class="field">
|
|
104
|
+
<span class="label">Port</span>
|
|
105
|
+
<input type="number" value=${draft.port}
|
|
106
|
+
onInput=${(e) => update({ port: e.target.value })} />
|
|
107
|
+
<span class="hint">restart server to apply</span>
|
|
108
|
+
</label>
|
|
109
|
+
<label class="field">
|
|
110
|
+
<span class="label">Work directory</span>
|
|
111
|
+
<input type="text" value=${draft.workDir}
|
|
112
|
+
onInput=${(e) => update({ workDir: e.target.value })} />
|
|
113
|
+
</label>
|
|
114
|
+
<label class="field">
|
|
115
|
+
<span class="label">Snapshot interval (ms)</span>
|
|
116
|
+
<input type="number" min="5000" value=${draft.snapshotIntervalMs}
|
|
117
|
+
onInput=${(e) => update({ snapshotIntervalMs: e.target.value })} />
|
|
118
|
+
</label>
|
|
119
|
+
<label class="field">
|
|
120
|
+
<span class="label">History kept</span>
|
|
121
|
+
<input type="number" min="1" value=${draft.snapshotHistoryKeep}
|
|
122
|
+
onInput=${(e) => update({ snapshotHistoryKeep: e.target.value })} />
|
|
123
|
+
</label>
|
|
124
|
+
<label class="field">
|
|
125
|
+
<span class="label">Claude command</span>
|
|
126
|
+
<input type="text" placeholder="claude" value=${draft.claudeCommand}
|
|
127
|
+
onInput=${(e) => update({ claudeCommand: e.target.value })} />
|
|
128
|
+
<span class="hint">alias / function / exe name</span>
|
|
129
|
+
</label>
|
|
130
|
+
<label class="field">
|
|
131
|
+
<span class="label">Terminal</span>
|
|
132
|
+
<select class="input" value=${draft.terminal}
|
|
133
|
+
onChange=${(e) => update({ terminal: e.target.value })}>
|
|
134
|
+
${(terminals.value || []).map((t) => html`
|
|
135
|
+
<option key=${t.name} value=${t.name}>${t.name} · ${t.processName}</option>`)}
|
|
136
|
+
</select>
|
|
137
|
+
</label>
|
|
138
|
+
<label class="field">
|
|
139
|
+
<span class="label">Command shell <span class="hint inline">(wt only)</span></span>
|
|
140
|
+
<select class="input" value=${draft.commandShell}
|
|
141
|
+
onChange=${(e) => update({ commandShell: e.target.value })}>
|
|
142
|
+
<option value="pwsh">pwsh · PowerShell 7</option>
|
|
143
|
+
<option value="powershell">powershell · Windows PowerShell 5.1</option>
|
|
144
|
+
<option value="none">none · run command directly</option>
|
|
145
|
+
</select>
|
|
146
|
+
</label>
|
|
147
|
+
<label class="field">
|
|
148
|
+
<span class="label">Browser open mode</span>
|
|
149
|
+
<select class="input" value=${draft.browserMode}
|
|
150
|
+
onChange=${(e) => update({ browserMode: e.target.value })}>
|
|
151
|
+
<option value="app">app · Edge/Chrome chromeless</option>
|
|
152
|
+
<option value="tab">tab · default browser</option>
|
|
153
|
+
<option value="none">off · don't open</option>
|
|
154
|
+
</select>
|
|
155
|
+
</label>
|
|
156
|
+
<label class="field toggle">
|
|
157
|
+
<input type="checkbox" checked=${draft.autoFocusOnLaunch}
|
|
158
|
+
onChange=${(e) => update({ autoFocusOnLaunch: e.target.checked })} />
|
|
159
|
+
<span class="toggle-text">
|
|
160
|
+
<span class="label">Auto-focus on launch</span>
|
|
161
|
+
<span class="hint">raise newly-launched terminal window</span>
|
|
162
|
+
</span>
|
|
163
|
+
</label>
|
|
164
|
+
<label class="field toggle">
|
|
165
|
+
<input type="checkbox" checked=${draft.focusMovesToCenter}
|
|
166
|
+
onChange=${(e) => update({ focusMovesToCenter: e.target.checked })} />
|
|
167
|
+
<span class="toggle-text">
|
|
168
|
+
<span class="label">Move focused window to screen center</span>
|
|
169
|
+
<span class="hint">centers the focused window on whichever monitor the cursor is on</span>
|
|
170
|
+
</span>
|
|
171
|
+
</label>
|
|
172
|
+
<label class="field full">
|
|
173
|
+
<span class="label">Finder prompt</span>
|
|
174
|
+
<textarea rows="3" value=${draft.finderPrompt}
|
|
175
|
+
onInput=${(e) => update({ finderPrompt: e.target.value })}></textarea>
|
|
176
|
+
<span class="hint">passed as initial prompt to the finder session</span>
|
|
177
|
+
</label>
|
|
178
|
+
|
|
179
|
+
<div class="field full">
|
|
180
|
+
<div class="repos-head">
|
|
181
|
+
<span class="label">Repositories</span>
|
|
182
|
+
<button class="action small" onClick=${() => { addEmptyRepo(reposChanged); }}>+ Add repo</button>
|
|
183
|
+
</div>
|
|
184
|
+
<${ReposEditor} onChange=${reposChanged} />
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="form-actions full">
|
|
188
|
+
<button class=${`action primary${configDirty.value ? ' is-dirty' : ''}`}
|
|
189
|
+
onClick=${onSave}>Save configuration</button>
|
|
190
|
+
<span class="muted-text">${savedAt}</span>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</${Card}>`;
|
|
194
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { useState } from 'preact/hooks';
|
|
3
|
+
import { signal } from '@preact/signals';
|
|
4
|
+
import { capabilities, activeTerminalId, selectTab } from '../state.js';
|
|
5
|
+
import { loadWorkspaces, loadWebTerminals } from '../api.js';
|
|
6
|
+
import { setToast } from '../toast.js';
|
|
7
|
+
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
8
|
+
import { Card } from '../components/Card.js';
|
|
9
|
+
import { RepoPicker } from '../components/RepoPicker.js';
|
|
10
|
+
import { WorkspacePicker } from '../components/WorkspacePicker.js';
|
|
11
|
+
import { ProgressList } from '../components/ProgressList.js';
|
|
12
|
+
import { WorkspacesGrid, WorkspacesHeader } from '../components/WorkspacesGrid.js';
|
|
13
|
+
import { SnapshotPanel, SnapshotMeta } from '../components/SnapshotPanel.js';
|
|
14
|
+
|
|
15
|
+
const ROOT_ID = 'newSessionProgress';
|
|
16
|
+
const inlineSelected = signal(new Set());
|
|
17
|
+
|
|
18
|
+
function NewSessionCard() {
|
|
19
|
+
const [workspace, setWorkspace] = useState('');
|
|
20
|
+
// 'web' = run inside this page (in-process PTY · bridges to xterm.js)
|
|
21
|
+
// 'wt' = open a new Windows Terminal window
|
|
22
|
+
const initialMode = capabilities.value.webTerminal ? 'web' : 'wt';
|
|
23
|
+
const [terminal, setTerminal] = useState(initialMode);
|
|
24
|
+
const [result, setResult] = useState('');
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
|
|
27
|
+
const onLaunch = async () => {
|
|
28
|
+
const repos = [...inlineSelected.value];
|
|
29
|
+
if (repos.length === 0) return setToast('select at least one repo', 'error');
|
|
30
|
+
setBusy(true);
|
|
31
|
+
setResult('');
|
|
32
|
+
resetProgress(repos, ROOT_ID);
|
|
33
|
+
try {
|
|
34
|
+
const final = await streamNewSession(
|
|
35
|
+
{ repos, workspace: workspace || undefined, terminal },
|
|
36
|
+
{
|
|
37
|
+
progressRootId: ROOT_ID,
|
|
38
|
+
onMeta: (ev) => {
|
|
39
|
+
if (ev.type === 'workspace') {
|
|
40
|
+
setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
|
|
41
|
+
} else if (ev.type === 'launched') {
|
|
42
|
+
const l = ev.launched || {};
|
|
43
|
+
if (l.mode === 'web') {
|
|
44
|
+
setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
|
|
45
|
+
} else {
|
|
46
|
+
setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
if (final.success) {
|
|
53
|
+
const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
54
|
+
setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} — ${summary}`);
|
|
55
|
+
setToast(`launched · ${final.workspace.name}`);
|
|
56
|
+
// For web mode, hop to the Terminals tab and open the new session.
|
|
57
|
+
if (terminal === 'web' && final.launched?.id) {
|
|
58
|
+
activeTerminalId.value = final.launched.id;
|
|
59
|
+
await loadWebTerminals();
|
|
60
|
+
selectTab('terminals');
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
setResult(`error: ${final.error}`);
|
|
64
|
+
setToast(final.error || 'new session failed', 'error');
|
|
65
|
+
}
|
|
66
|
+
await loadWorkspaces();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setResult(`error: ${e.message}`);
|
|
69
|
+
setToast(e.message, 'error');
|
|
70
|
+
} finally {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return html`
|
|
76
|
+
<${Card} title="New session"
|
|
77
|
+
meta=${html`Picks an unused workspace, clones missing repos, opens <code>claude</code> in a fresh terminal.`}>
|
|
78
|
+
<div class="form-row">
|
|
79
|
+
<span class="form-label">Repos</span>
|
|
80
|
+
<${RepoPicker} selectedSig=${inlineSelected} />
|
|
81
|
+
</div>
|
|
82
|
+
${capabilities.value.webTerminal ? html`
|
|
83
|
+
<div class="form-row">
|
|
84
|
+
<span class="form-label">Open in</span>
|
|
85
|
+
<div class="radio-row">
|
|
86
|
+
<label class=${`radio${terminal === 'web' ? ' is-checked' : ''}`}>
|
|
87
|
+
<input type="radio" name="terminal" value="web"
|
|
88
|
+
checked=${terminal === 'web'} onChange=${() => setTerminal('web')} />
|
|
89
|
+
this page
|
|
90
|
+
</label>
|
|
91
|
+
<label class=${`radio${terminal === 'wt' ? ' is-checked' : ''}`}>
|
|
92
|
+
<input type="radio" name="terminal" value="wt"
|
|
93
|
+
checked=${terminal === 'wt'} onChange=${() => setTerminal('wt')} />
|
|
94
|
+
wt window
|
|
95
|
+
</label>
|
|
96
|
+
</div>
|
|
97
|
+
</div>` : null}
|
|
98
|
+
<div class="form-row">
|
|
99
|
+
<label class="form-label">Workspace</label>
|
|
100
|
+
<${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
|
|
101
|
+
<button class="action primary" disabled=${busy} onClick=${onLaunch}>Launch new session</button>
|
|
102
|
+
</div>
|
|
103
|
+
<${ProgressList} rootId=${ROOT_ID} />
|
|
104
|
+
<div class="post-result">${result}</div>
|
|
105
|
+
</${Card}>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function LaunchPage() {
|
|
109
|
+
return html`
|
|
110
|
+
<${NewSessionCard} />
|
|
111
|
+
<${Card} title="Snapshot & restore" meta=${html`<${SnapshotMeta} />`}>
|
|
112
|
+
<${SnapshotPanel} />
|
|
113
|
+
</${Card}>
|
|
114
|
+
<${Card} title="Workspaces on disk" meta=${html`<${WorkspacesHeader} />`}>
|
|
115
|
+
<${WorkspacesGrid} />
|
|
116
|
+
</${Card}>`;
|
|
117
|
+
}
|