@bakapiano/ccsm 0.6.0 → 0.8.4
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 +377 -123
- 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 +132 -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,68 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import {
|
|
3
|
+
recent, recentTotal, recentOffset, recentLimit, clockTick,
|
|
4
|
+
} from '../state.js';
|
|
5
|
+
import { loadRecent } from '../api.js';
|
|
6
|
+
import { fmtAgo, fmtTime } from '../util.js';
|
|
7
|
+
import { resumeSession } from '../actions.js';
|
|
8
|
+
import { TitleCell } from './TitleCell.js';
|
|
9
|
+
import { Pagination } from './Pagination.js';
|
|
10
|
+
import { IconExternal } from '../icons.js';
|
|
11
|
+
|
|
12
|
+
export function RecentTable() {
|
|
13
|
+
void clockTick.value;
|
|
14
|
+
const list = recent.value;
|
|
15
|
+
|
|
16
|
+
return html`
|
|
17
|
+
<div class="table-scroll">
|
|
18
|
+
<table class="data">
|
|
19
|
+
<thead>
|
|
20
|
+
<tr>
|
|
21
|
+
<th>Title</th>
|
|
22
|
+
<th>Working directory</th>
|
|
23
|
+
<th>Branch</th>
|
|
24
|
+
<th class="num">Last activity</th>
|
|
25
|
+
<th class="num">Started</th>
|
|
26
|
+
<th class="col-actions"></th>
|
|
27
|
+
</tr>
|
|
28
|
+
</thead>
|
|
29
|
+
<tbody>${list.map((s) => html`<${Row} key=${s.sessionId} session=${s} />`)}</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
${list.length === 0 ? html`<div class="empty">Nothing in <code>~/.claude/projects/</code>.</div>` : null}
|
|
33
|
+
<${Pagination}
|
|
34
|
+
total=${recentTotal.value}
|
|
35
|
+
offset=${recentOffset.value}
|
|
36
|
+
limit=${recentLimit.value}
|
|
37
|
+
onChange=${(off, lim) => {
|
|
38
|
+
recentOffset.value = off;
|
|
39
|
+
recentLimit.value = lim;
|
|
40
|
+
loadRecent().catch(() => {});
|
|
41
|
+
}} />`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Row({ session: s }) {
|
|
45
|
+
return html`
|
|
46
|
+
<tr>
|
|
47
|
+
<td>
|
|
48
|
+
<${TitleCell}
|
|
49
|
+
sessionId=${s.sessionId}
|
|
50
|
+
title=${s.title}
|
|
51
|
+
snapshotData=${{ cwd: s.cwd || '', title: s.title, gitBranch: s.gitBranch || '' }} />
|
|
52
|
+
</td>
|
|
53
|
+
<td><div class="path-cell" title=${s.cwd || ''}>${s.cwd || ''}</div></td>
|
|
54
|
+
<td>
|
|
55
|
+
${s.gitBranch ? html`<span class="branch-tag">${s.gitBranch}</span>` : html`<span class="muted-text">—</span>`}
|
|
56
|
+
</td>
|
|
57
|
+
<td class="num" title=${fmtTime(s.updatedAt)}>${fmtAgo(s.updatedAt)}</td>
|
|
58
|
+
<td class="num" title=${fmtTime(s.startedAt)}>${fmtAgo(s.startedAt)}</td>
|
|
59
|
+
<td>
|
|
60
|
+
<div class="row-actions">
|
|
61
|
+
<button class="action small" title="claude --resume in a fresh wt window"
|
|
62
|
+
onClick=${() => resumeSession(s.sessionId, s.cwd, { kind: 'continue' })}>
|
|
63
|
+
<${IconExternal} /> Continue
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</td>
|
|
67
|
+
</tr>`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Chip multi-select. Selected names are tracked locally per-instance
|
|
2
|
+
// because the inline form and the modal can have different selections.
|
|
3
|
+
|
|
4
|
+
import { html } from '../html.js';
|
|
5
|
+
import { config } from '../state.js';
|
|
6
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
+
|
|
8
|
+
export function RepoPicker({ selectedSig }) {
|
|
9
|
+
const repos = config.value?.repos || [];
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
// initialise to default-selected repos on first mount + whenever the set
|
|
13
|
+
// of available repos changes (so a newly-added default flips on)
|
|
14
|
+
const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
|
|
15
|
+
selectedSig.value = want;
|
|
16
|
+
// we only want to re-init when the repo NAMES change, not on every render
|
|
17
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
18
|
+
}, [repos.map((r) => r.name + ':' + r.defaultSelected).join('|')]);
|
|
19
|
+
|
|
20
|
+
if (repos.length === 0) {
|
|
21
|
+
return html`<span class="muted-text">no repos configured · use <strong>+ Add repo</strong> below</span>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const toggle = (name, on) => {
|
|
25
|
+
const next = new Set(selectedSig.value);
|
|
26
|
+
if (on) next.add(name);
|
|
27
|
+
else next.delete(name);
|
|
28
|
+
selectedSig.value = next;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return html`<div class="chip-row">${repos.map((r) => {
|
|
32
|
+
const checked = selectedSig.value.has(r.name);
|
|
33
|
+
return html`
|
|
34
|
+
<label class=${`chip${checked ? ' checked' : ''}`} key=${r.name}>
|
|
35
|
+
<input type="checkbox" checked=${checked}
|
|
36
|
+
onChange=${(e) => toggle(r.name, e.target.checked)} />
|
|
37
|
+
${r.name}
|
|
38
|
+
</label>`;
|
|
39
|
+
})}</div>`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Inline editable repos table — used in two places (Configure tab inline,
|
|
2
|
+
// and inside the new-session modal's "Manage repos" disclosure).
|
|
3
|
+
//
|
|
4
|
+
// Row inputs are locally controlled (useState) and only flush to the
|
|
5
|
+
// config signal on blur. Without this, every keystroke would mutate
|
|
6
|
+
// config.value and cascade a re-render through every config consumer
|
|
7
|
+
// (Footer, RepoPicker, WorkspacesHeader, the other ReposEditor instance),
|
|
8
|
+
// which made typing feel laggy.
|
|
9
|
+
|
|
10
|
+
import { html } from '../html.js';
|
|
11
|
+
import { useState, useEffect } from 'preact/hooks';
|
|
12
|
+
import { config } from '../state.js';
|
|
13
|
+
|
|
14
|
+
export function ReposEditor({ onChange }) {
|
|
15
|
+
const repos = config.value?.repos || [];
|
|
16
|
+
|
|
17
|
+
const commit = (idx, patch) => {
|
|
18
|
+
const next = (config.value?.repos || []).map((r, i) => i === idx ? { ...r, ...patch } : r);
|
|
19
|
+
config.value = { ...config.value, repos: next };
|
|
20
|
+
onChange?.(next);
|
|
21
|
+
};
|
|
22
|
+
const remove = (idx) => {
|
|
23
|
+
const next = (config.value?.repos || []).filter((_, i) => i !== idx);
|
|
24
|
+
config.value = { ...config.value, repos: next };
|
|
25
|
+
onChange?.(next);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return html`
|
|
29
|
+
<table class="data repos-table">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr>
|
|
32
|
+
<th>Name</th>
|
|
33
|
+
<th>URL</th>
|
|
34
|
+
<th class="num">Default</th>
|
|
35
|
+
<th class="col-actions"></th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
${repos.map((r, idx) => html`
|
|
40
|
+
<${Row} key=${idx} idx=${idx} repo=${r} commit=${commit} remove=${remove} />`)}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Row({ idx, repo, commit, remove }) {
|
|
46
|
+
const [name, setName] = useState(repo.name);
|
|
47
|
+
const [url, setUrl] = useState(repo.url);
|
|
48
|
+
// Keep local state in sync when the underlying repo changes from
|
|
49
|
+
// outside (e.g. a fresh /api/config GET after Save). We compare against
|
|
50
|
+
// the prop so unrelated cascades don't clobber in-progress edits.
|
|
51
|
+
useEffect(() => { setName(repo.name); }, [repo.name]);
|
|
52
|
+
useEffect(() => { setUrl(repo.url); }, [repo.url]);
|
|
53
|
+
|
|
54
|
+
return html`
|
|
55
|
+
<tr>
|
|
56
|
+
<td><input type="text" value=${name}
|
|
57
|
+
onInput=${(e) => setName(e.target.value)}
|
|
58
|
+
onBlur=${() => name !== repo.name && commit(idx, { name })} /></td>
|
|
59
|
+
<td><input type="text" value=${url}
|
|
60
|
+
onInput=${(e) => setUrl(e.target.value)}
|
|
61
|
+
onBlur=${() => url !== repo.url && commit(idx, { url })} /></td>
|
|
62
|
+
<td class="num"><input type="checkbox" checked=${!!repo.defaultSelected}
|
|
63
|
+
onChange=${(e) => commit(idx, { defaultSelected: e.target.checked })} /></td>
|
|
64
|
+
<td><div class="row-actions">
|
|
65
|
+
<button class="action tiny danger" onClick=${() => remove(idx)}>Remove</button>
|
|
66
|
+
</div></td>
|
|
67
|
+
</tr>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function addEmptyRepo(onChange) {
|
|
71
|
+
const repos = [...(config.value?.repos || []), { name: '', url: '', defaultSelected: false }];
|
|
72
|
+
config.value = { ...config.value, repos };
|
|
73
|
+
onChange?.(repos);
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { serverHealth } from '../state.js';
|
|
3
|
+
|
|
4
|
+
export function ServerStatus() {
|
|
5
|
+
const h = serverHealth.value;
|
|
6
|
+
const view = {
|
|
7
|
+
online: { text: h.version ? `online · v${h.version}` : 'online',
|
|
8
|
+
title: `backend ok · pid ${h.pid} · v${h.version}` },
|
|
9
|
+
offline: { text: 'offline', title: `backend unreachable — ${h.error || ''}` },
|
|
10
|
+
connecting: { text: 'connecting…', title: 'checking backend status' },
|
|
11
|
+
}[h.state] || { text: h.state, title: h.state };
|
|
12
|
+
|
|
13
|
+
return html`
|
|
14
|
+
<span class="server-status" data-state=${h.state} title=${view.title}>
|
|
15
|
+
<span class="status-pulse" aria-hidden="true"></span>
|
|
16
|
+
<span class="server-status-label">${view.text}</span>
|
|
17
|
+
</span>`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import {
|
|
3
|
+
sessions, sessionsOffset, sessionsLimit, clockTick,
|
|
4
|
+
} from '../state.js';
|
|
5
|
+
import { fmtAgo, fmtTime } from '../util.js';
|
|
6
|
+
import { focusSession } from '../actions.js';
|
|
7
|
+
import { TitleCell } from './TitleCell.js';
|
|
8
|
+
import { Pagination } from './Pagination.js';
|
|
9
|
+
import { IconMonitor } from '../icons.js';
|
|
10
|
+
|
|
11
|
+
export function SessionsTable() {
|
|
12
|
+
// touch clockTick so fmtAgo refreshes each second
|
|
13
|
+
void clockTick.value;
|
|
14
|
+
|
|
15
|
+
const all = sessions.value;
|
|
16
|
+
if (sessionsOffset.value >= all.length) {
|
|
17
|
+
sessionsOffset.value = Math.max(0, Math.floor((all.length - 1) / sessionsLimit.value) * sessionsLimit.value);
|
|
18
|
+
}
|
|
19
|
+
const slice = all.slice(sessionsOffset.value, sessionsOffset.value + sessionsLimit.value);
|
|
20
|
+
|
|
21
|
+
return html`
|
|
22
|
+
<div class="table-scroll">
|
|
23
|
+
<table class="data">
|
|
24
|
+
<thead>
|
|
25
|
+
<tr>
|
|
26
|
+
<th class="col-mark"></th>
|
|
27
|
+
<th>Title</th>
|
|
28
|
+
<th>Working directory</th>
|
|
29
|
+
<th class="num">Updated</th>
|
|
30
|
+
<th class="num">Started</th>
|
|
31
|
+
<th class="num">PID</th>
|
|
32
|
+
<th class="col-actions"></th>
|
|
33
|
+
</tr>
|
|
34
|
+
</thead>
|
|
35
|
+
<tbody>${slice.map((s) => html`<${Row} key=${s.sessionId} session=${s} />`)}</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
</div>
|
|
38
|
+
${all.length === 0 ? html`<div class="empty">No live sessions detected.</div>` : null}
|
|
39
|
+
<${Pagination}
|
|
40
|
+
total=${all.length}
|
|
41
|
+
offset=${sessionsOffset.value}
|
|
42
|
+
limit=${sessionsLimit.value}
|
|
43
|
+
onChange=${(off, lim) => { sessionsOffset.value = off; sessionsLimit.value = lim; }} />`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Row({ session: s }) {
|
|
47
|
+
const versionExtra = s.version ? html` · ${s.version}` : null;
|
|
48
|
+
return html`
|
|
49
|
+
<tr>
|
|
50
|
+
<td><span class=${`status-mark ${s.status}`} title=${s.status}></span></td>
|
|
51
|
+
<td>
|
|
52
|
+
<${TitleCell}
|
|
53
|
+
sessionId=${s.sessionId}
|
|
54
|
+
title=${s.title}
|
|
55
|
+
secondaryExtra=${versionExtra}
|
|
56
|
+
snapshotData=${{ cwd: s.cwd, title: s.title, gitBranch: s.gitBranch || '' }} />
|
|
57
|
+
</td>
|
|
58
|
+
<td><div class="path-cell" title=${s.cwd}>${s.cwd}</div></td>
|
|
59
|
+
<td class="num" title=${fmtTime(s.updatedAt)}>${fmtAgo(s.updatedAt)}</td>
|
|
60
|
+
<td class="num" title=${fmtTime(s.startedAt)}>${fmtAgo(s.startedAt)}</td>
|
|
61
|
+
<td class="num">${s.pid}</td>
|
|
62
|
+
<td>
|
|
63
|
+
<div class="row-actions">
|
|
64
|
+
<button class="action small" title="raise the wt window already running this session"
|
|
65
|
+
onClick=${() => focusSession(s.sessionId)}>
|
|
66
|
+
<${IconMonitor} /> Focus
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import {
|
|
3
|
+
activeTab, sidebarCollapsed, configDirty, sessions, webTerminals, capabilities,
|
|
4
|
+
selectTab, toggleSidebar,
|
|
5
|
+
} from '../state.js';
|
|
6
|
+
import {
|
|
7
|
+
IconSessions, IconLaunch, IconTerminal, IconConfigure, IconInfo,
|
|
8
|
+
IconChevronLeft, BrandMark,
|
|
9
|
+
} from '../icons.js';
|
|
10
|
+
|
|
11
|
+
function NavItem({ tab, icon, label, badge, dirty }) {
|
|
12
|
+
const selected = activeTab.value === tab;
|
|
13
|
+
const cls = `nav-item${dirty ? ' has-changes' : ''}`;
|
|
14
|
+
return html`
|
|
15
|
+
<button class=${cls} role="tab" aria-selected=${selected ? 'true' : 'false'}
|
|
16
|
+
onClick=${() => selectTab(tab)}>
|
|
17
|
+
<span class="nav-icon">${icon}</span>
|
|
18
|
+
<span class="nav-label">${label}</span>
|
|
19
|
+
${badge != null ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
20
|
+
</button>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Sidebar() {
|
|
24
|
+
const collapsed = sidebarCollapsed.value;
|
|
25
|
+
|
|
26
|
+
return html`
|
|
27
|
+
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
28
|
+
<div class="sidebar-brand">
|
|
29
|
+
<span class="brand-mark"><${BrandMark} /></span>
|
|
30
|
+
<span class="brand-name">CCSM<span class="brand-dot">.</span></span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<nav class="sidebar-nav" role="tablist" aria-label="Sections">
|
|
34
|
+
<${NavItem} tab="sessions" icon=${html`<${IconSessions} />`} label="Sessions" badge=${sessions.value.length} />
|
|
35
|
+
<${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="Launch" />
|
|
36
|
+
${capabilities.value.webTerminal ? html`
|
|
37
|
+
<${NavItem} tab="terminals" icon=${html`<${IconTerminal} />`} label="Terminals" badge=${webTerminals.value.length || null} />
|
|
38
|
+
` : null}
|
|
39
|
+
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Configure" dirty=${configDirty.value} />
|
|
40
|
+
<${NavItem} tab="about" icon=${html`<${IconInfo} />`} label="About" />
|
|
41
|
+
</nav>
|
|
42
|
+
|
|
43
|
+
<div class="sidebar-foot">
|
|
44
|
+
<button class="util-item collapse-toggle" aria-label="collapse sidebar"
|
|
45
|
+
title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
|
|
46
|
+
onClick=${toggleSidebar}>
|
|
47
|
+
<span class="nav-icon"><${IconChevronLeft} /></span>
|
|
48
|
+
<span class="nav-label">Collapse</span>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</aside>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { useState } from 'preact/hooks';
|
|
3
|
+
import { snapshot, history, clockTick } from '../state.js';
|
|
4
|
+
import { api, loadSnapshot } from '../api.js';
|
|
5
|
+
import { fmtAgo, fmtTime } from '../util.js';
|
|
6
|
+
import { setToast } from '../toast.js';
|
|
7
|
+
import { ccsmConfirm } from '../dialog.js';
|
|
8
|
+
|
|
9
|
+
export function SnapshotMeta() {
|
|
10
|
+
void clockTick.value;
|
|
11
|
+
const snap = snapshot.value;
|
|
12
|
+
const text = snap
|
|
13
|
+
? `${snap.sessions.length} session(s) · taken ${fmtAgo(snap.takenAt)} ago (${fmtTime(snap.takenAt)})`
|
|
14
|
+
: 'no snapshot saved yet';
|
|
15
|
+
return html`${text}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SnapshotPanel() {
|
|
19
|
+
const [selectedHistory, setSelectedHistory] = useState('');
|
|
20
|
+
const snap = snapshot.value;
|
|
21
|
+
const hist = history.value;
|
|
22
|
+
|
|
23
|
+
const preview = snap
|
|
24
|
+
? snap.sessions.map((s) =>
|
|
25
|
+
`${(s.title || s.sessionId.slice(0, 8)).padEnd(44).slice(0, 44)} ${s.cwd}`
|
|
26
|
+
).join('\n')
|
|
27
|
+
: '';
|
|
28
|
+
|
|
29
|
+
const onSave = async () => {
|
|
30
|
+
try {
|
|
31
|
+
const r = await api('POST', '/api/snapshot');
|
|
32
|
+
snapshot.value = r.snapshot;
|
|
33
|
+
const h = await api('GET', '/api/snapshot/history');
|
|
34
|
+
history.value = h.history;
|
|
35
|
+
setToast(`saved · ${r.snapshot.sessions.length} session(s)`);
|
|
36
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
37
|
+
};
|
|
38
|
+
const onRestoreLatest = async () => {
|
|
39
|
+
if (!snap || !snap.sessions.length) return setToast('no sessions in snapshot', 'error');
|
|
40
|
+
const ok = await ccsmConfirm(
|
|
41
|
+
`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`,
|
|
42
|
+
{ title: 'Restore latest snapshot', okLabel: `Restore ${snap.sessions.length}` },
|
|
43
|
+
);
|
|
44
|
+
if (!ok) return;
|
|
45
|
+
try {
|
|
46
|
+
const r = await api('POST', '/api/snapshot/restore');
|
|
47
|
+
setToast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
48
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
49
|
+
};
|
|
50
|
+
const onRestoreHistory = async () => {
|
|
51
|
+
if (!selectedHistory) return setToast('pick a history snapshot first', 'error');
|
|
52
|
+
const ok = await ccsmConfirm(`Restore from ${selectedHistory}?`, {
|
|
53
|
+
title: 'Restore from history', okLabel: 'Restore',
|
|
54
|
+
});
|
|
55
|
+
if (!ok) return;
|
|
56
|
+
try {
|
|
57
|
+
const r = await api('POST', '/api/snapshot/restore', { file: selectedHistory });
|
|
58
|
+
setToast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
59
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return html`
|
|
63
|
+
<div class="row gap-row">
|
|
64
|
+
<button class="action" onClick=${onSave}>Save snapshot now</button>
|
|
65
|
+
<button class="action primary" onClick=${onRestoreLatest}>Restore latest</button>
|
|
66
|
+
<span class="divider-dot">·</span>
|
|
67
|
+
<select class="input narrow" value=${selectedHistory} onChange=${(e) => setSelectedHistory(e.target.value)}>
|
|
68
|
+
<option value="">history…</option>
|
|
69
|
+
${hist.map((h) => html`<option key=${h.file} value=${h.file}>${h.file.replace('.json', '')}</option>`)}
|
|
70
|
+
</select>
|
|
71
|
+
<button class="action" onClick=${onRestoreHistory}>Restore selected</button>
|
|
72
|
+
</div>
|
|
73
|
+
<details class="snapshot-detail">
|
|
74
|
+
<summary>View snapshot contents</summary>
|
|
75
|
+
<pre class="preview">${preview}</pre>
|
|
76
|
+
</details>`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// xterm.js wrapper. Mounts a terminal into a ref'd div, opens a WebSocket
|
|
2
|
+
// to /ws/terminal/<id>, forwards keystrokes/resize as JSON frames, renders
|
|
3
|
+
// output frames into xterm. Disposes everything on unmount or id change.
|
|
4
|
+
|
|
5
|
+
import { html } from '../html.js';
|
|
6
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
7
|
+
import { Terminal } from '@xterm/xterm';
|
|
8
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
9
|
+
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
10
|
+
import { wsBase } from '../backend.js';
|
|
11
|
+
|
|
12
|
+
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
13
|
+
// match what claude code's TUI assumes (it paints its own input box +
|
|
14
|
+
// prompt with hardcoded dark backgrounds — a light terminal makes those
|
|
15
|
+
// regions look like black blocks). Cursor uses the favorite-star gold so
|
|
16
|
+
// it pops against the ink without dragging brand orange back in.
|
|
17
|
+
const THEME = {
|
|
18
|
+
background: '#1a1815',
|
|
19
|
+
foreground: '#e8e3d5',
|
|
20
|
+
cursor: '#e3b341',
|
|
21
|
+
cursorAccent: '#1a1815',
|
|
22
|
+
selectionBackground: '#3a3530',
|
|
23
|
+
black: '#1a1815', brightBlack: '#534e44',
|
|
24
|
+
red: '#e07b6e', brightRed: '#f0a098',
|
|
25
|
+
green: '#7fb670', brightGreen: '#a0d28f',
|
|
26
|
+
yellow: '#e3b341', brightYellow: '#f0c860',
|
|
27
|
+
blue: '#7d9fc4', brightBlue: '#9bb8d8',
|
|
28
|
+
magenta: '#c08fd0', brightMagenta: '#d8aae2',
|
|
29
|
+
cyan: '#6fb0b0', brightCyan: '#90c8c8',
|
|
30
|
+
white: '#e8e3d5', brightWhite: '#faf9f5',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function TerminalView({ terminalId }) {
|
|
34
|
+
const hostRef = useRef(null);
|
|
35
|
+
const termRef = useRef(null);
|
|
36
|
+
const wsRef = useRef(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!terminalId || !hostRef.current) return;
|
|
40
|
+
|
|
41
|
+
const term = new Terminal({
|
|
42
|
+
fontFamily: '"JetBrains Mono", "Cascadia Mono", Consolas, monospace',
|
|
43
|
+
fontSize: 13,
|
|
44
|
+
lineHeight: 1.2,
|
|
45
|
+
cursorBlink: true,
|
|
46
|
+
cursorStyle: 'bar',
|
|
47
|
+
scrollback: 5000,
|
|
48
|
+
allowProposedApi: true,
|
|
49
|
+
theme: THEME,
|
|
50
|
+
});
|
|
51
|
+
const fit = new FitAddon();
|
|
52
|
+
term.loadAddon(fit);
|
|
53
|
+
term.loadAddon(new WebLinksAddon());
|
|
54
|
+
term.open(hostRef.current);
|
|
55
|
+
// Defer fit one tick so the container has measured layout
|
|
56
|
+
requestAnimationFrame(() => { try { fit.fit(); } catch {} });
|
|
57
|
+
termRef.current = term;
|
|
58
|
+
|
|
59
|
+
const ws = new WebSocket(`${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}`);
|
|
60
|
+
ws.binaryType = 'arraybuffer';
|
|
61
|
+
wsRef.current = ws;
|
|
62
|
+
|
|
63
|
+
ws.onopen = () => {
|
|
64
|
+
// tell server the initial size (cols/rows after fit)
|
|
65
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
66
|
+
};
|
|
67
|
+
ws.onmessage = (ev) => {
|
|
68
|
+
let frame;
|
|
69
|
+
try { frame = JSON.parse(ev.data); } catch { return; }
|
|
70
|
+
if (frame.type === 'output') {
|
|
71
|
+
term.write(frame.data);
|
|
72
|
+
} else if (frame.type === 'exit') {
|
|
73
|
+
term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
ws.onclose = () => {
|
|
77
|
+
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onData = (data) => {
|
|
81
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
82
|
+
};
|
|
83
|
+
const onResize = ({ cols, rows }) => {
|
|
84
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
85
|
+
};
|
|
86
|
+
term.onData(onData);
|
|
87
|
+
term.onResize(onResize);
|
|
88
|
+
|
|
89
|
+
const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
|
|
90
|
+
ro.observe(hostRef.current);
|
|
91
|
+
|
|
92
|
+
// give focus to terminal so user can type immediately
|
|
93
|
+
term.focus();
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
ro.disconnect();
|
|
97
|
+
try { ws.close(); } catch {}
|
|
98
|
+
try { term.dispose(); } catch {}
|
|
99
|
+
termRef.current = null;
|
|
100
|
+
wsRef.current = null;
|
|
101
|
+
};
|
|
102
|
+
}, [terminalId]);
|
|
103
|
+
|
|
104
|
+
if (!terminalId) {
|
|
105
|
+
return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
|
|
106
|
+
}
|
|
107
|
+
return html`<div ref=${hostRef} class="terminal-host"></div>`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Title cell shared by the three row types: shown text + rename + star.
|
|
2
|
+
// Includes the session-id secondary line and the row hover-only icon set.
|
|
3
|
+
|
|
4
|
+
import { html } from '../html.js';
|
|
5
|
+
import { labels, favorites } from '../state.js';
|
|
6
|
+
import { displayTitle } from '../util.js';
|
|
7
|
+
import { toggleFavorite, renameSession } from '../actions.js';
|
|
8
|
+
import { StarFilled, StarOutline, IconPencil } from '../icons.js';
|
|
9
|
+
|
|
10
|
+
export function TitleCell({ sessionId, title, secondaryExtra, snapshotData }) {
|
|
11
|
+
const label = labels.value[sessionId];
|
|
12
|
+
const hasLabel = !!label;
|
|
13
|
+
const isFav = !!favorites.value[sessionId];
|
|
14
|
+
const shown = displayTitle(label, title);
|
|
15
|
+
const tooltip = hasLabel ? `${shown}\n(original: ${title || '—'})` : shown;
|
|
16
|
+
|
|
17
|
+
const onRename = (ev) => { ev.stopPropagation(); renameSession(sessionId, label || ''); };
|
|
18
|
+
const onStar = (ev) => { ev.stopPropagation(); toggleFavorite(sessionId, snapshotData); };
|
|
19
|
+
|
|
20
|
+
return html`
|
|
21
|
+
<div class="title-cell">
|
|
22
|
+
<div class="title-row">
|
|
23
|
+
<span class="primary" title=${tooltip}>${shown}</span>
|
|
24
|
+
<button class=${`rename-btn${hasLabel ? ' has-label' : ''}`}
|
|
25
|
+
title=${hasLabel ? 'rename · custom label set' : 'rename'}
|
|
26
|
+
aria-label="rename" onClick=${onRename}>
|
|
27
|
+
<${IconPencil} />
|
|
28
|
+
</button>
|
|
29
|
+
<button class=${`star-btn${isFav ? ' is-fav' : ''}`}
|
|
30
|
+
title=${isFav ? 'remove favorite' : 'add favorite'}
|
|
31
|
+
aria-label=${isFav ? 'remove favorite' : 'add favorite'}
|
|
32
|
+
onClick=${onStar}>
|
|
33
|
+
${isFav ? html`<${StarFilled} />` : html`<${StarOutline} />`}
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="secondary" title=${sessionId}>
|
|
37
|
+
${sessionId.slice(0, 8)}${secondaryExtra || null}
|
|
38
|
+
</div>
|
|
39
|
+
</div>`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { toastState } from '../toast.js';
|
|
3
|
+
|
|
4
|
+
export function Toast() {
|
|
5
|
+
const t = toastState.value;
|
|
6
|
+
const cls = `toast ${t.visible ? 'show' : ''} ${t.kind}`;
|
|
7
|
+
return html`<div class=${cls} role="status" aria-live="polite">${t.msg}</div>`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// "auto" + every workspace. We deliberately don't filter by inUse —
|
|
2
|
+
// frontend's view can be stale and the server validates the chosen name
|
|
3
|
+
// on the request anyway. inUse is only used as a visual marker on the
|
|
4
|
+
// option label so the user has the info.
|
|
5
|
+
|
|
6
|
+
import { html } from '../html.js';
|
|
7
|
+
import { workspaces } from '../state.js';
|
|
8
|
+
|
|
9
|
+
export function WorkspacePicker({ value, onChange }) {
|
|
10
|
+
const all = workspaces.value;
|
|
11
|
+
return html`
|
|
12
|
+
<select class="input narrow" value=${value} onChange=${(e) => onChange(e.target.value)}>
|
|
13
|
+
<option value="">auto — find or create unused</option>
|
|
14
|
+
${all.map((w) => html`
|
|
15
|
+
<option key=${w.name} value=${w.name}>
|
|
16
|
+
${w.name}${w.inUse ? ' · in use' : ''}
|
|
17
|
+
</option>`)}
|
|
18
|
+
</select>`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import { workspaces, config } from '../state.js';
|
|
3
|
+
|
|
4
|
+
export function WorkspacesGrid() {
|
|
5
|
+
const list = workspaces.value;
|
|
6
|
+
if (list.length === 0) {
|
|
7
|
+
return html`<div class="empty">No workspaces yet — the first launch will create one.</div>`;
|
|
8
|
+
}
|
|
9
|
+
return html`
|
|
10
|
+
<div class="workspace-grid">
|
|
11
|
+
${list.map((w) => html`<${Card} key=${w.name} ws=${w} />`)}
|
|
12
|
+
</div>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Card({ ws }) {
|
|
16
|
+
const cls = 'workspace-card' + (ws.inUse ? ' in-use' : '');
|
|
17
|
+
const tag = ws.inUse ? `in use × ${ws.sessionsHere.length}` : 'free';
|
|
18
|
+
return html`
|
|
19
|
+
<div class=${cls}>
|
|
20
|
+
<div class="ws-head">
|
|
21
|
+
<div class="ws-name">${ws.name}</div>
|
|
22
|
+
<span class="ws-tag">${tag}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="ws-path">${ws.path}</div>
|
|
25
|
+
<div class="ws-repos">
|
|
26
|
+
${ws.repos.map((r) => html`
|
|
27
|
+
<span class=${`ws-repo${r.cloned ? ' cloned' : ''}`} title=${r.url} key=${r.name}>
|
|
28
|
+
${r.name}${r.cloned ? ' ✓' : ''}
|
|
29
|
+
</span>`)}
|
|
30
|
+
</div>
|
|
31
|
+
</div>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function WorkspacesHeader() {
|
|
35
|
+
return html`Under <code>${config.value?.workDir || '…'}</code>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wrapping component so the meta updates when config changes
|
|
39
|
+
export function WorkspacesHeaderInline() {
|
|
40
|
+
return WorkspacesHeader();
|
|
41
|
+
}
|