@bakapiano/ccsm 0.9.0 → 0.10.1
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 +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- package/public/js/pages/TerminalsPage.js +0 -74
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
// FAB-triggered launch modal. Shares ReposEditor + RepoPicker with the
|
|
2
|
-
// inline Launch tab form, but maintains its own selected-repos + workspace
|
|
3
|
-
// state so the two don't clobber each other.
|
|
4
|
-
|
|
5
|
-
import { html } from '../html.js';
|
|
6
|
-
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
-
import { signal } from '@preact/signals';
|
|
8
|
-
import { modalOpen, config, capabilities, activeTerminalId, selectTab } from '../state.js';
|
|
9
|
-
import { api, loadWorkspaces, loadWebTerminals } from '../api.js';
|
|
10
|
-
import { setToast } from '../toast.js';
|
|
11
|
-
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
12
|
-
import { IconClose } from '../icons.js';
|
|
13
|
-
import { RepoPicker } from './RepoPicker.js';
|
|
14
|
-
import { WorkspacePicker } from './WorkspacePicker.js';
|
|
15
|
-
import { ProgressList } from './ProgressList.js';
|
|
16
|
-
import { ReposEditor, addEmptyRepo } from './ReposEditor.js';
|
|
17
|
-
|
|
18
|
-
const ROOT_ID = 'modalProgress';
|
|
19
|
-
const modalSelected = signal(new Set());
|
|
20
|
-
|
|
21
|
-
// Top-level mounts ModalBody only when open. Keeping hooks inside ModalBody
|
|
22
|
-
// means hook count is stable across its lifetime (was previously being
|
|
23
|
-
// declared after a conditional return, which fights Preact's hook contract
|
|
24
|
-
// and makes opening feel laggy).
|
|
25
|
-
export function NewSessionModal() {
|
|
26
|
-
return modalOpen.value ? html`<${ModalBody} />` : null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function ModalBody() {
|
|
30
|
-
const [workspace, setWorkspace] = useState('');
|
|
31
|
-
const [result, setResult] = useState('');
|
|
32
|
-
const [busy, setBusy] = useState(false);
|
|
33
|
-
const [reposSavedAt, setReposSavedAt] = useState('');
|
|
34
|
-
|
|
35
|
-
const close = () => {
|
|
36
|
-
setResult('');
|
|
37
|
-
setBusy(false);
|
|
38
|
-
modalOpen.value = false;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
const onKey = (ev) => { if (ev.key === 'Escape') close(); };
|
|
43
|
-
document.addEventListener('keydown', onKey);
|
|
44
|
-
const prev = document.body.style.overflow;
|
|
45
|
-
document.body.style.overflow = 'hidden';
|
|
46
|
-
return () => {
|
|
47
|
-
document.removeEventListener('keydown', onKey);
|
|
48
|
-
document.body.style.overflow = prev;
|
|
49
|
-
};
|
|
50
|
-
}, []);
|
|
51
|
-
|
|
52
|
-
const onLaunch = async () => {
|
|
53
|
-
const repos = [...modalSelected.value];
|
|
54
|
-
setBusy(true);
|
|
55
|
-
setResult('');
|
|
56
|
-
resetProgress(repos, ROOT_ID);
|
|
57
|
-
const wantWeb = capabilities.value?.webTerminal
|
|
58
|
-
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
59
|
-
const terminal = wantWeb ? 'web' : 'wt';
|
|
60
|
-
try {
|
|
61
|
-
const final = await streamNewSession(
|
|
62
|
-
{ repos, workspace: workspace || undefined, terminal },
|
|
63
|
-
{
|
|
64
|
-
progressRootId: ROOT_ID,
|
|
65
|
-
onMeta: (ev) => {
|
|
66
|
-
if (ev.type === 'workspace') {
|
|
67
|
-
setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
|
|
68
|
-
} else if (ev.type === 'launched') {
|
|
69
|
-
const l = ev.launched || {};
|
|
70
|
-
if (l.mode === 'web') setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
|
|
71
|
-
else setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
if (final.success) {
|
|
77
|
-
const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
78
|
-
setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''}${summary ? ' — ' + summary : ''}`);
|
|
79
|
-
setToast(`launched · ${final.workspace.name}`);
|
|
80
|
-
if (terminal === 'web' && final.launched?.id) {
|
|
81
|
-
activeTerminalId.value = final.launched.id;
|
|
82
|
-
await loadWebTerminals();
|
|
83
|
-
selectTab('terminals');
|
|
84
|
-
modalOpen.value = false;
|
|
85
|
-
} else {
|
|
86
|
-
setTimeout(close, 1500);
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
setResult(`error: ${final.error}`);
|
|
90
|
-
setToast(final.error || 'new session failed', 'error');
|
|
91
|
-
}
|
|
92
|
-
await loadWorkspaces();
|
|
93
|
-
} catch (e) {
|
|
94
|
-
setResult(`error: ${e.message}`);
|
|
95
|
-
setToast(e.message, 'error');
|
|
96
|
-
} finally {
|
|
97
|
-
setBusy(false);
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const onSaveRepos = async () => {
|
|
102
|
-
try {
|
|
103
|
-
const cfg = await api('PUT', '/api/config', config.value);
|
|
104
|
-
config.value = cfg;
|
|
105
|
-
setReposSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
|
|
106
|
-
setToast('repos saved');
|
|
107
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const onBackdropClick = (ev) => { if (ev.currentTarget === ev.target) close(); };
|
|
111
|
-
|
|
112
|
-
return html`
|
|
113
|
-
<div class="modal-backdrop" role="dialog" aria-modal="true" onClick=${onBackdropClick}>
|
|
114
|
-
<div class="modal">
|
|
115
|
-
<header class="modal-head">
|
|
116
|
-
<h2>Launch new session</h2>
|
|
117
|
-
<button class="modal-close" aria-label="close" onClick=${close}><${IconClose} /></button>
|
|
118
|
-
</header>
|
|
119
|
-
<div class="modal-body">
|
|
120
|
-
<p class="modal-hint">Pick an unused workspace, clone any missing repos, open <code>claude</code> in a fresh terminal.</p>
|
|
121
|
-
|
|
122
|
-
<div class="form-row">
|
|
123
|
-
<span class="form-label">Repos</span>
|
|
124
|
-
<${RepoPicker} selectedSig=${modalSelected} />
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
<details class="repos-inline-config">
|
|
128
|
-
<summary>Manage repos</summary>
|
|
129
|
-
<div class="repos-inline-body">
|
|
130
|
-
<${ReposEditor} />
|
|
131
|
-
<div class="repos-inline-actions">
|
|
132
|
-
<button class="action small" onClick=${() => addEmptyRepo()}>+ Add repo</button>
|
|
133
|
-
<button class="action small primary" onClick=${onSaveRepos}>Save changes</button>
|
|
134
|
-
<span class="muted-text">${reposSavedAt}</span>
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
</details>
|
|
138
|
-
|
|
139
|
-
<div class="form-row">
|
|
140
|
-
<label class="form-label">Workspace</label>
|
|
141
|
-
<${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<${ProgressList} rootId=${ROOT_ID} />
|
|
145
|
-
<div class="post-result">${result}</div>
|
|
146
|
-
</div>
|
|
147
|
-
<footer class="modal-foot">
|
|
148
|
-
<button class="action" onClick=${close}>Cancel</button>
|
|
149
|
-
<button class="action primary" disabled=${busy} onClick=${onLaunch}>Launch new session</button>
|
|
150
|
-
</footer>
|
|
151
|
-
</div>
|
|
152
|
-
</div>`;
|
|
153
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import { activeTab, TAB_HEADINGS, lastRefreshAt, clockTick } from '../state.js';
|
|
3
|
-
import { refreshAll } from '../api.js';
|
|
4
|
-
import { setToast } from '../toast.js';
|
|
5
|
-
import { fmtAgo } from '../util.js';
|
|
6
|
-
import { ServerStatus } from './ServerStatus.js';
|
|
7
|
-
import { IconRefresh } from '../icons.js';
|
|
8
|
-
|
|
9
|
-
export function PageHead() {
|
|
10
|
-
const heading = TAB_HEADINGS[activeTab.value] || TAB_HEADINGS.sessions;
|
|
11
|
-
// subscribe to clockTick so the "Ns ago" relative label updates every second
|
|
12
|
-
void clockTick.value;
|
|
13
|
-
const last = lastRefreshAt.value;
|
|
14
|
-
const lastLabel = last ? `${fmtAgo(last)} ago` : 'never';
|
|
15
|
-
|
|
16
|
-
const onRefresh = () =>
|
|
17
|
-
refreshAll().then(() => setToast('refreshed')).catch((e) => setToast(e.message, 'error'));
|
|
18
|
-
|
|
19
|
-
return html`
|
|
20
|
-
<header class="page-head">
|
|
21
|
-
<div class="page-head-inner">
|
|
22
|
-
<h1 class="page-title">${heading.title}</h1>
|
|
23
|
-
<p class="page-subtitle">${heading.subtitle}</p>
|
|
24
|
-
</div>
|
|
25
|
-
<div class="page-head-meta">
|
|
26
|
-
<${ServerStatus} />
|
|
27
|
-
<button class="action subtle small" title=${`last refresh: ${lastLabel}`} onClick=${onRefresh}>
|
|
28
|
-
<${IconRefresh} size=${13} /> Refresh
|
|
29
|
-
<span class="refresh-ago">${lastLabel}</span>
|
|
30
|
-
</button>
|
|
31
|
-
</div>
|
|
32
|
-
</header>`;
|
|
33
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// Shared pagination footer. Hidden when total ≤ limit.
|
|
2
|
-
// onChange(nextOffset, nextLimit) is called for both arrow clicks and page-size change.
|
|
3
|
-
|
|
4
|
-
import { html } from '../html.js';
|
|
5
|
-
|
|
6
|
-
export function Pagination({ total, offset, limit, onChange }) {
|
|
7
|
-
if (total <= limit) return null;
|
|
8
|
-
const pageNum = Math.floor(offset / limit) + 1;
|
|
9
|
-
const pageTotal = Math.max(1, Math.ceil(total / limit));
|
|
10
|
-
const prev = () => onChange(Math.max(0, offset - limit), limit);
|
|
11
|
-
const next = () => onChange(offset + limit, limit);
|
|
12
|
-
const resize = (e) => onChange(0, Math.max(1, Number(e.target.value) || 10));
|
|
13
|
-
|
|
14
|
-
return html`
|
|
15
|
-
<footer class="pagination">
|
|
16
|
-
<button class="action subtle small" disabled=${offset === 0} onClick=${prev}>← Prev</button>
|
|
17
|
-
<span class="pagination-info">
|
|
18
|
-
Page <strong>${pageNum}</strong> of <strong>${pageTotal}</strong> · <span>${total}</span> total
|
|
19
|
-
</span>
|
|
20
|
-
<button class="action subtle small" disabled=${offset + limit >= total} onClick=${next}>Next →</button>
|
|
21
|
-
<select class="input" style="max-width: 100px;" value=${limit} onChange=${resize}>
|
|
22
|
-
<option value="10">10 / page</option>
|
|
23
|
-
<option value="20">20 / page</option>
|
|
24
|
-
<option value="50">50 / page</option>
|
|
25
|
-
</select>
|
|
26
|
-
</footer>`;
|
|
27
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
// Left rail · list of active web sessions + right pane with selected one's xterm.
|
|
2
|
-
|
|
3
|
-
import { html } from '../html.js';
|
|
4
|
-
import { useEffect } from 'preact/hooks';
|
|
5
|
-
import { webTerminals, activeTerminalId, selectTab } from '../state.js';
|
|
6
|
-
import { loadWebTerminals, killWebTerminal } from '../api.js';
|
|
7
|
-
import { setToast } from '../toast.js';
|
|
8
|
-
import { ccsmConfirm } from '../dialog.js';
|
|
9
|
-
import { TerminalView } from '../components/TerminalView.js';
|
|
10
|
-
import { fmtAgo } from '../util.js';
|
|
11
|
-
|
|
12
|
-
export function TerminalsPage() {
|
|
13
|
-
const list = webTerminals.value;
|
|
14
|
-
const activeId = activeTerminalId.value;
|
|
15
|
-
|
|
16
|
-
// Auto-select the first available terminal if nothing is active
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
if (!activeId && list.length > 0) activeTerminalId.value = list[0].id;
|
|
19
|
-
if (activeId && !list.find((t) => t.id === activeId)) {
|
|
20
|
-
activeTerminalId.value = list[0]?.id || null;
|
|
21
|
-
}
|
|
22
|
-
}, [list.map((t) => t.id).join('|'), activeId]);
|
|
23
|
-
|
|
24
|
-
if (list.length === 0) {
|
|
25
|
-
return html`
|
|
26
|
-
<div class="terminal-empty-page">
|
|
27
|
-
<div class="card">
|
|
28
|
-
<div class="card-body" style="text-align: center; padding: 60px var(--s-6);">
|
|
29
|
-
<p style="font-size: 14px; color: var(--ink-mid); margin-bottom: var(--s-4);">
|
|
30
|
-
No terminals open yet.
|
|
31
|
-
</p>
|
|
32
|
-
<button class="action primary" onClick=${() => selectTab('launch')}>
|
|
33
|
-
+ Launch a session
|
|
34
|
-
</button>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
</div>`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return html`
|
|
41
|
-
<div class="terminals-layout">
|
|
42
|
-
<aside class="terminals-rail">
|
|
43
|
-
<div class="terminals-rail-head">
|
|
44
|
-
<span>${list.length} active</span>
|
|
45
|
-
<button class="action subtle tiny" title="refresh list" onClick=${() => loadWebTerminals()}>↻</button>
|
|
46
|
-
</div>
|
|
47
|
-
${list.map((t) => html`
|
|
48
|
-
<button key=${t.id} class=${`terminal-row${activeId === t.id ? ' is-active' : ''}`}
|
|
49
|
-
onClick=${() => (activeTerminalId.value = t.id)}>
|
|
50
|
-
<span class=${`status-mark ${t.exitedAt ? 'unknown' : 'busy'}`}></span>
|
|
51
|
-
<span class="terminal-row-title">${t.meta.title || t.id.slice(0, 12)}</span>
|
|
52
|
-
<span class="terminal-row-meta">${fmtAgo(t.meta.startedAt)}</span>
|
|
53
|
-
<span class="terminal-row-actions">
|
|
54
|
-
<button class="action tiny danger" title="kill this session"
|
|
55
|
-
onClick=${(ev) => { ev.stopPropagation(); confirmKill(t); }}>×</button>
|
|
56
|
-
</span>
|
|
57
|
-
</button>`)}
|
|
58
|
-
</aside>
|
|
59
|
-
<main class="terminals-main">
|
|
60
|
-
<${TerminalView} terminalId=${activeId} />
|
|
61
|
-
</main>
|
|
62
|
-
</div>`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function confirmKill(t) {
|
|
66
|
-
const ok = await ccsmConfirm(`Kill ${t.meta.title || t.id}? The PTY process will be terminated.`, {
|
|
67
|
-
title: 'Kill session', okLabel: 'Kill', danger: true,
|
|
68
|
-
});
|
|
69
|
-
if (!ok) return;
|
|
70
|
-
try {
|
|
71
|
-
await killWebTerminal(t.id);
|
|
72
|
-
setToast(`killed · ${t.id.slice(0, 12)}`);
|
|
73
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
74
|
-
}
|