@bakapiano/ccsm 0.5.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/config.js +1 -0
- package/lib/favorites.js +23 -45
- package/lib/focus.js +90 -14
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +29 -0
- package/lib/webTerminal.js +173 -0
- package/lib/workspace.js +8 -4
- 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 +18 -0
- package/public/index.html +53 -379
- 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 +314 -31
- package/public/app.js +0 -894
- package/public/styles.css +0 -1204
package/public/app.js
DELETED
|
@@ -1,894 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/* ─────────────────────────────────────────────────────────────
|
|
4
|
-
ccsm · frontend · v0.6 (light sidebar)
|
|
5
|
-
───────────────────────────────────────────────────────────── */
|
|
6
|
-
|
|
7
|
-
const $ = (sel) => document.querySelector(sel);
|
|
8
|
-
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|
9
|
-
|
|
10
|
-
const state = {
|
|
11
|
-
config: null,
|
|
12
|
-
terminals: [],
|
|
13
|
-
sessions: [],
|
|
14
|
-
recent: [],
|
|
15
|
-
recentTotal: 0,
|
|
16
|
-
recentOffset: 0,
|
|
17
|
-
recentLimit: 15,
|
|
18
|
-
favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
|
|
19
|
-
workspaces: [],
|
|
20
|
-
snapshot: null,
|
|
21
|
-
history: [],
|
|
22
|
-
autoTimer: null,
|
|
23
|
-
clockTimer: null,
|
|
24
|
-
activeTab: 'sessions',
|
|
25
|
-
// Tables that have already had their first render — used to suppress the
|
|
26
|
-
// row stagger animation on subsequent re-renders so 5s auto-refresh
|
|
27
|
-
// doesn't strobe.
|
|
28
|
-
renderedTables: new Set(),
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const TAB_HEADINGS = {
|
|
32
|
-
sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
|
|
33
|
-
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
|
|
34
|
-
configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/* ── API ── */
|
|
38
|
-
async function api(method, url, body) {
|
|
39
|
-
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
40
|
-
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
41
|
-
const r = await fetch(url, opts);
|
|
42
|
-
const text = await r.text();
|
|
43
|
-
let json;
|
|
44
|
-
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
45
|
-
if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
|
|
46
|
-
return json;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/* ── toast ── */
|
|
50
|
-
const toastEl = $('#toast');
|
|
51
|
-
let toastT;
|
|
52
|
-
function toast(msg, kind = 'ok') {
|
|
53
|
-
toastEl.textContent = msg;
|
|
54
|
-
toastEl.className = `toast show ${kind}`;
|
|
55
|
-
clearTimeout(toastT);
|
|
56
|
-
toastT = setTimeout(() => toastEl.classList.remove('show'), 3200);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/* ── fmt ── */
|
|
60
|
-
function fmtTime(ms) {
|
|
61
|
-
if (!ms) return '—';
|
|
62
|
-
return new Date(ms).toLocaleString(undefined, { hour12: false });
|
|
63
|
-
}
|
|
64
|
-
function fmtAgo(ms) {
|
|
65
|
-
if (!ms) return '—';
|
|
66
|
-
const sec = Math.floor((Date.now() - ms) / 1000);
|
|
67
|
-
if (sec < 60) return `${sec}s`;
|
|
68
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
69
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
|
|
70
|
-
return `${Math.floor(sec / 86400)}d`;
|
|
71
|
-
}
|
|
72
|
-
function escapeHtml(s) {
|
|
73
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
|
74
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
75
|
-
}[c]));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/* Mark a table as already-rendered so animations don't replay on
|
|
79
|
-
subsequent updates. Call after the first innerHTML population. */
|
|
80
|
-
function markRendered(tableId) {
|
|
81
|
-
const tb = document.querySelector(`#${tableId} tbody`);
|
|
82
|
-
if (!tb) return;
|
|
83
|
-
if (state.renderedTables.has(tableId)) {
|
|
84
|
-
tb.classList.add('no-anim');
|
|
85
|
-
} else {
|
|
86
|
-
state.renderedTables.add(tableId);
|
|
87
|
-
// first render: animation runs. We schedule no-anim for next paint
|
|
88
|
-
// so the very next re-render doesn't restage.
|
|
89
|
-
requestAnimationFrame(() => {
|
|
90
|
-
requestAnimationFrame(() => tb.classList.add('no-anim'));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const STAR_SVG_OUTLINE =
|
|
96
|
-
`<svg class="star-icon" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
97
|
-
<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"/>
|
|
98
|
-
</svg>`;
|
|
99
|
-
const STAR_SVG_FILLED =
|
|
100
|
-
`<svg class="star-icon" viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
101
|
-
<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"/>
|
|
102
|
-
</svg>`;
|
|
103
|
-
|
|
104
|
-
function starButtonHtml(sessionId, isFav) {
|
|
105
|
-
return `<button class="star-btn ${isFav ? 'is-fav' : ''}" data-star="${escapeHtml(sessionId)}" title="${isFav ? 'remove favorite' : 'add favorite'}" aria-label="${isFav ? 'remove favorite' : 'add favorite'}">${isFav ? STAR_SVG_FILLED : STAR_SVG_OUTLINE}</button>`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/* ─────────────────────────────────────────────────────────────
|
|
109
|
-
Sidebar — tabs + collapse
|
|
110
|
-
───────────────────────────────────────────────────────────── */
|
|
111
|
-
|
|
112
|
-
function selectTab(name) {
|
|
113
|
-
if (!TAB_HEADINGS[name]) name = 'sessions';
|
|
114
|
-
state.activeTab = name;
|
|
115
|
-
$$('.nav-item').forEach((b) => {
|
|
116
|
-
b.setAttribute('aria-selected', b.dataset.tab === name ? 'true' : 'false');
|
|
117
|
-
});
|
|
118
|
-
$$('.tab-panel').forEach((p) => {
|
|
119
|
-
if (p.dataset.panel === name) p.setAttribute('data-active', '');
|
|
120
|
-
else p.removeAttribute('data-active');
|
|
121
|
-
});
|
|
122
|
-
const h = TAB_HEADINGS[name];
|
|
123
|
-
$('#pageTitle').textContent = h.title;
|
|
124
|
-
$('#pageSubtitle').textContent = h.subtitle;
|
|
125
|
-
if (location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function toggleSidebar() {
|
|
129
|
-
const sb = $('#sidebar');
|
|
130
|
-
const collapsed = sb.getAttribute('data-collapsed') === 'true';
|
|
131
|
-
sb.setAttribute('data-collapsed', collapsed ? 'false' : 'true');
|
|
132
|
-
localStorage.setItem('ccsm.sidebar-collapsed', collapsed ? 'false' : 'true');
|
|
133
|
-
}
|
|
134
|
-
function restoreSidebar() {
|
|
135
|
-
const v = localStorage.getItem('ccsm.sidebar-collapsed');
|
|
136
|
-
if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/* ─────────────────────────────────────────────────────────────
|
|
140
|
-
Render: sessions (live)
|
|
141
|
-
───────────────────────────────────────────────────────────── */
|
|
142
|
-
|
|
143
|
-
function renderSessions() {
|
|
144
|
-
const tb = $('#sessionsTable tbody');
|
|
145
|
-
tb.innerHTML = '';
|
|
146
|
-
for (const s of state.sessions) {
|
|
147
|
-
const isFav = !!state.favorites[s.sessionId];
|
|
148
|
-
const tr = document.createElement('tr');
|
|
149
|
-
tr.innerHTML = `
|
|
150
|
-
<td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
|
|
151
|
-
<td>
|
|
152
|
-
<div class="title-cell">
|
|
153
|
-
<div class="title-row">
|
|
154
|
-
<span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
|
|
155
|
-
${starButtonHtml(s.sessionId, isFav)}
|
|
156
|
-
</div>
|
|
157
|
-
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
|
|
158
|
-
</div>
|
|
159
|
-
</td>
|
|
160
|
-
<td><div class="path-cell" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
|
|
161
|
-
<td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
|
|
162
|
-
<td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
|
|
163
|
-
<td class="num">${escapeHtml(String(s.pid))}</td>
|
|
164
|
-
<td>
|
|
165
|
-
<div class="row-actions">
|
|
166
|
-
<button class="action small primary" data-focus="${escapeHtml(s.sessionId)}" title="raise the wt window already running this session">Focus</button>
|
|
167
|
-
<button class="action small" data-resume="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">Resume new ↗</button>
|
|
168
|
-
</div>
|
|
169
|
-
</td>
|
|
170
|
-
`;
|
|
171
|
-
tr.dataset.cwd = s.cwd;
|
|
172
|
-
tr.dataset.title = s.title || '';
|
|
173
|
-
tb.appendChild(tr);
|
|
174
|
-
}
|
|
175
|
-
$('#sessionsEmpty').hidden = state.sessions.length > 0;
|
|
176
|
-
const ts = new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
177
|
-
$('#sessionsMeta').textContent = state.sessions.length
|
|
178
|
-
? `${state.sessions.length} live · refreshed ${ts}`
|
|
179
|
-
: 'no live sessions';
|
|
180
|
-
$('#navCount-sessions').textContent = state.sessions.length;
|
|
181
|
-
markRendered('sessionsTable');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/* ─────────────────────────────────────────────────────────────
|
|
185
|
-
Render: recently closed
|
|
186
|
-
───────────────────────────────────────────────────────────── */
|
|
187
|
-
|
|
188
|
-
function renderRecent() {
|
|
189
|
-
const tb = $('#recentTable tbody');
|
|
190
|
-
tb.innerHTML = '';
|
|
191
|
-
const recent = state.recent || [];
|
|
192
|
-
for (const s of recent) {
|
|
193
|
-
const isFav = !!state.favorites[s.sessionId];
|
|
194
|
-
const tr = document.createElement('tr');
|
|
195
|
-
tr.innerHTML = `
|
|
196
|
-
<td>
|
|
197
|
-
<div class="title-cell">
|
|
198
|
-
<div class="title-row">
|
|
199
|
-
<span class="primary" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</span>
|
|
200
|
-
${starButtonHtml(s.sessionId, isFav)}
|
|
201
|
-
</div>
|
|
202
|
-
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
|
|
203
|
-
</div>
|
|
204
|
-
</td>
|
|
205
|
-
<td><div class="path-cell" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
|
|
206
|
-
<td>${s.gitBranch ? `<span class="branch-tag">${escapeHtml(s.gitBranch)}</span>` : '<span class="muted-text">—</span>'}</td>
|
|
207
|
-
<td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
|
|
208
|
-
<td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
|
|
209
|
-
<td>
|
|
210
|
-
<div class="row-actions">
|
|
211
|
-
<button class="action small primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="claude --resume in a fresh wt window">Continue ↗</button>
|
|
212
|
-
</div>
|
|
213
|
-
</td>
|
|
214
|
-
`;
|
|
215
|
-
tr.dataset.cwd = s.cwd || '';
|
|
216
|
-
tr.dataset.title = s.title || '';
|
|
217
|
-
tr.dataset.gitBranch = s.gitBranch || '';
|
|
218
|
-
tb.appendChild(tr);
|
|
219
|
-
}
|
|
220
|
-
$('#recentEmpty').hidden = recent.length > 0;
|
|
221
|
-
// Pagination footer
|
|
222
|
-
const total = state.recentTotal;
|
|
223
|
-
const limit = state.recentLimit;
|
|
224
|
-
const offset = state.recentOffset;
|
|
225
|
-
const pageNum = Math.floor(offset / limit) + 1;
|
|
226
|
-
const pageTotal = Math.max(1, Math.ceil(total / limit));
|
|
227
|
-
$('#recentMeta').textContent = total
|
|
228
|
-
? `${total} total · sorted by jsonl mtime, excluding live`
|
|
229
|
-
: 'no recent sessions';
|
|
230
|
-
if (total > limit) {
|
|
231
|
-
$('#recentPagination').hidden = false;
|
|
232
|
-
$('#recentPageNum').textContent = pageNum;
|
|
233
|
-
$('#recentPageTotal').textContent = pageTotal;
|
|
234
|
-
$('#recentTotal').textContent = total;
|
|
235
|
-
$('#recentPrevBtn').disabled = offset === 0;
|
|
236
|
-
$('#recentNextBtn').disabled = offset + limit >= total;
|
|
237
|
-
} else {
|
|
238
|
-
$('#recentPagination').hidden = true;
|
|
239
|
-
}
|
|
240
|
-
markRendered('recentTable');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/* ─────────────────────────────────────────────────────────────
|
|
244
|
-
Render: favorites
|
|
245
|
-
───────────────────────────────────────────────────────────── */
|
|
246
|
-
function renderFavorites() {
|
|
247
|
-
const tb = $('#favoritesTable tbody');
|
|
248
|
-
tb.innerHTML = '';
|
|
249
|
-
const list = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
|
250
|
-
for (const f of list) {
|
|
251
|
-
const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
|
|
252
|
-
const title = liveMatch?.title || f.title;
|
|
253
|
-
const cwd = liveMatch?.cwd || f.cwd;
|
|
254
|
-
const branch = f.gitBranch;
|
|
255
|
-
const actions = liveMatch
|
|
256
|
-
? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
|
|
257
|
-
<button class="action small" data-resume="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd)}" title="claude --resume in a fresh wt window">Resume new ↗</button>`
|
|
258
|
-
: `<button class="action small primary" data-continue="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd || '')}" ${cwd ? '' : 'disabled'} title="claude --resume in a fresh wt window">Continue ↗</button>`;
|
|
259
|
-
const tr = document.createElement('tr');
|
|
260
|
-
tr.innerHTML = `
|
|
261
|
-
<td>
|
|
262
|
-
<div class="title-cell">
|
|
263
|
-
<div class="title-row">
|
|
264
|
-
<span class="primary" title="${escapeHtml(title || '')}">${escapeHtml(title || '(no title)')}</span>
|
|
265
|
-
${starButtonHtml(f.sessionId, true)}
|
|
266
|
-
</div>
|
|
267
|
-
<div class="secondary" title="${escapeHtml(f.sessionId)}">
|
|
268
|
-
${escapeHtml(f.sessionId.slice(0, 8))}${liveMatch ? ` · <span style="color:var(--green);">live</span>` : ''}
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
271
|
-
</td>
|
|
272
|
-
<td><div class="path-cell" title="${escapeHtml(cwd || '')}">${escapeHtml(cwd || '')}</div></td>
|
|
273
|
-
<td>${branch ? `<span class="branch-tag">${escapeHtml(branch)}</span>` : '<span class="muted-text">—</span>'}</td>
|
|
274
|
-
<td class="num" title="${escapeHtml(fmtTime(f.addedAt))}">${escapeHtml(fmtAgo(f.addedAt))}</td>
|
|
275
|
-
<td><div class="row-actions">${actions}</div></td>
|
|
276
|
-
`;
|
|
277
|
-
tr.dataset.cwd = cwd || '';
|
|
278
|
-
tr.dataset.title = title || '';
|
|
279
|
-
tb.appendChild(tr);
|
|
280
|
-
}
|
|
281
|
-
const count = list.length;
|
|
282
|
-
$('#favoritesEmpty').style.display = count === 0 ? 'block' : 'none';
|
|
283
|
-
$('#favoritesTable').style.display = count === 0 ? 'none' : 'table';
|
|
284
|
-
$('#favoritesMeta').textContent = count
|
|
285
|
-
? `${count} pinned`
|
|
286
|
-
: 'click ☆ on any row to pin sessions here';
|
|
287
|
-
markRendered('favoritesTable');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/* ─────────────────────────────────────────────────────────────
|
|
291
|
-
Render: snapshot
|
|
292
|
-
───────────────────────────────────────────────────────────── */
|
|
293
|
-
|
|
294
|
-
function renderSnapshot() {
|
|
295
|
-
const snap = state.snapshot;
|
|
296
|
-
if (!snap) {
|
|
297
|
-
$('#snapshotMeta').textContent = 'no snapshot saved yet';
|
|
298
|
-
$('#snapshotPreview').textContent = '';
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
$('#snapshotMeta').textContent =
|
|
302
|
-
`${snap.sessions.length} session(s) · taken ${fmtAgo(snap.takenAt)} ago (${fmtTime(snap.takenAt)})`;
|
|
303
|
-
$('#snapshotPreview').textContent =
|
|
304
|
-
snap.sessions.map((s) =>
|
|
305
|
-
`${(s.title || s.sessionId.slice(0, 8)).padEnd(44).slice(0, 44)} ${s.cwd}`
|
|
306
|
-
).join('\n');
|
|
307
|
-
|
|
308
|
-
const sel = $('#historySelect');
|
|
309
|
-
sel.innerHTML = '<option value="">history…</option>' +
|
|
310
|
-
state.history.map((h) =>
|
|
311
|
-
`<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json', ''))}</option>`
|
|
312
|
-
).join('');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/* ─────────────────────────────────────────────────────────────
|
|
316
|
-
Render: workspaces
|
|
317
|
-
───────────────────────────────────────────────────────────── */
|
|
318
|
-
|
|
319
|
-
function renderWorkspaces() {
|
|
320
|
-
const grid = $('#workspaceList');
|
|
321
|
-
grid.innerHTML = '';
|
|
322
|
-
if (state.workspaces.length === 0) {
|
|
323
|
-
grid.innerHTML = '<div class="empty">No workspaces yet — the first launch will create one.</div>';
|
|
324
|
-
}
|
|
325
|
-
for (const w of state.workspaces) {
|
|
326
|
-
const repos = w.repos.map((r) =>
|
|
327
|
-
`<span class="ws-repo ${r.cloned ? 'cloned' : ''}" title="${escapeHtml(r.url)}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
|
|
328
|
-
).join('');
|
|
329
|
-
const card = document.createElement('div');
|
|
330
|
-
card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
|
|
331
|
-
card.innerHTML = `
|
|
332
|
-
<div class="ws-head">
|
|
333
|
-
<div class="ws-name">${escapeHtml(w.name)}</div>
|
|
334
|
-
<span class="ws-tag">${w.inUse ? `in use × ${w.sessionsHere.length}` : 'free'}</span>
|
|
335
|
-
</div>
|
|
336
|
-
<div class="ws-path">${escapeHtml(w.path)}</div>
|
|
337
|
-
<div class="ws-repos">${repos}</div>
|
|
338
|
-
`;
|
|
339
|
-
grid.appendChild(card);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const sel = $('#workspaceSelect');
|
|
343
|
-
sel.innerHTML = '<option value="">auto — find or create unused</option>' +
|
|
344
|
-
state.workspaces.filter((w) => !w.inUse).map((w) =>
|
|
345
|
-
`<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
|
|
346
|
-
).join('');
|
|
347
|
-
|
|
348
|
-
if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/* ─────────────────────────────────────────────────────────────
|
|
352
|
-
Render: repo picker
|
|
353
|
-
───────────────────────────────────────────────────────────── */
|
|
354
|
-
|
|
355
|
-
function renderRepoPicker() {
|
|
356
|
-
const root = $('#repoPicker');
|
|
357
|
-
const repos = state.config?.repos || [];
|
|
358
|
-
if (repos.length === 0) {
|
|
359
|
-
root.innerHTML = '<span class="muted-text">no repos configured · add some in <strong>Configure</strong></span>';
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
root.innerHTML = '';
|
|
363
|
-
for (const r of repos) {
|
|
364
|
-
const chip = document.createElement('label');
|
|
365
|
-
chip.className = 'chip' + (r.defaultSelected ? ' checked' : '');
|
|
366
|
-
chip.innerHTML = `<input type="checkbox" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
|
|
367
|
-
chip.querySelector('input').addEventListener('change', (e) => {
|
|
368
|
-
chip.classList.toggle('checked', e.target.checked);
|
|
369
|
-
});
|
|
370
|
-
root.appendChild(chip);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/* ─────────────────────────────────────────────────────────────
|
|
375
|
-
Render: config form
|
|
376
|
-
───────────────────────────────────────────────────────────── */
|
|
377
|
-
|
|
378
|
-
function renderConfig() {
|
|
379
|
-
if (!state.config) return;
|
|
380
|
-
$('#cfgPort').value = state.config.port;
|
|
381
|
-
$('#cfgWorkDir').value = state.config.workDir;
|
|
382
|
-
$('#cfgInterval').value = state.config.snapshotIntervalMs;
|
|
383
|
-
$('#cfgKeep').value = state.config.snapshotHistoryKeep;
|
|
384
|
-
$('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
|
|
385
|
-
$('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
|
|
386
|
-
$('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
|
|
387
|
-
$('#cfgBrowserMode').value =
|
|
388
|
-
state.config.browserMode ||
|
|
389
|
-
(state.config.autoOpenBrowser === false ? 'none' : 'app');
|
|
390
|
-
|
|
391
|
-
const termSel = $('#cfgTerminal');
|
|
392
|
-
termSel.innerHTML = (state.terminals || []).map((t) =>
|
|
393
|
-
`<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} · ${escapeHtml(t.processName)}</option>`
|
|
394
|
-
).join('');
|
|
395
|
-
|
|
396
|
-
$('#cfgFinderPrompt').value = state.config.finderPrompt || '';
|
|
397
|
-
|
|
398
|
-
const tb = $('#reposTable tbody');
|
|
399
|
-
tb.innerHTML = '';
|
|
400
|
-
(state.config.repos || []).forEach((r, idx) => {
|
|
401
|
-
const tr = document.createElement('tr');
|
|
402
|
-
tr.innerHTML = `
|
|
403
|
-
<td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" /></td>
|
|
404
|
-
<td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" /></td>
|
|
405
|
-
<td class="num"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
|
|
406
|
-
<td><div class="row-actions"><button class="action tiny danger" data-remove-repo="${idx}">Remove</button></div></td>
|
|
407
|
-
`;
|
|
408
|
-
tb.appendChild(tr);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function readConfigFromForm() {
|
|
413
|
-
const repos = $$('#reposTable tbody tr').map((tr) => {
|
|
414
|
-
const inputs = tr.querySelectorAll('input');
|
|
415
|
-
return {
|
|
416
|
-
name: inputs[0].value.trim(),
|
|
417
|
-
url: inputs[1].value.trim(),
|
|
418
|
-
defaultSelected: inputs[2].checked,
|
|
419
|
-
};
|
|
420
|
-
}).filter((r) => r.name && r.url);
|
|
421
|
-
|
|
422
|
-
return {
|
|
423
|
-
port: Number($('#cfgPort').value) || 7777,
|
|
424
|
-
workDir: $('#cfgWorkDir').value.trim(),
|
|
425
|
-
snapshotIntervalMs: Math.max(5000, Number($('#cfgInterval').value) || 60000),
|
|
426
|
-
snapshotHistoryKeep: Math.max(1, Number($('#cfgKeep').value) || 30),
|
|
427
|
-
claudeCommand: ($('#cfgClaudeCommand').value || 'claude').trim(),
|
|
428
|
-
terminal: $('#cfgTerminal').value || 'wt',
|
|
429
|
-
commandShell: $('#cfgCommandShell').value || 'pwsh',
|
|
430
|
-
autoFocusOnLaunch: $('#cfgAutoFocus').checked,
|
|
431
|
-
browserMode: $('#cfgBrowserMode').value || 'app',
|
|
432
|
-
finderPrompt: $('#cfgFinderPrompt').value,
|
|
433
|
-
repos,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/* ─────────────────────────────────────────────────────────────
|
|
438
|
-
Header + footer status
|
|
439
|
-
───────────────────────────────────────────────────────────── */
|
|
440
|
-
|
|
441
|
-
function renderHeaderStatus() {
|
|
442
|
-
if (!state.config) return;
|
|
443
|
-
$('#hdPort').textContent = String(state.config.port);
|
|
444
|
-
$('#hdTerminal').textContent =
|
|
445
|
-
`${state.config.terminal} · ${state.config.claudeCommand}` +
|
|
446
|
-
(state.config.terminal === 'wt' ? ` (${state.config.commandShell})` : '');
|
|
447
|
-
$('#footWorkDir').textContent = state.config.workDir;
|
|
448
|
-
$('#footData').textContent = '~/.ccsm';
|
|
449
|
-
}
|
|
450
|
-
function tickClock() {
|
|
451
|
-
const t = new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
452
|
-
const el = $('#hdTime');
|
|
453
|
-
if (el) el.textContent = t;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/* ─────────────────────────────────────────────────────────────
|
|
457
|
-
Loaders
|
|
458
|
-
───────────────────────────────────────────────────────────── */
|
|
459
|
-
|
|
460
|
-
async function loadConfig() {
|
|
461
|
-
const [cfg, terminals] = await Promise.all([
|
|
462
|
-
api('GET', '/api/config'),
|
|
463
|
-
api('GET', '/api/terminals'),
|
|
464
|
-
]);
|
|
465
|
-
state.config = cfg;
|
|
466
|
-
state.terminals = terminals.terminals;
|
|
467
|
-
renderConfig();
|
|
468
|
-
renderRepoPicker();
|
|
469
|
-
renderHeaderStatus();
|
|
470
|
-
}
|
|
471
|
-
async function loadSessions() {
|
|
472
|
-
const r = await api('GET', '/api/sessions');
|
|
473
|
-
state.sessions = r.sessions;
|
|
474
|
-
renderSessions();
|
|
475
|
-
}
|
|
476
|
-
async function loadRecent() {
|
|
477
|
-
const r = await api('GET', `/api/sessions/recent?limit=${state.recentLimit}&offset=${state.recentOffset}`);
|
|
478
|
-
state.recent = r.recent;
|
|
479
|
-
state.recentTotal = r.total || 0;
|
|
480
|
-
state.recentLimit = r.limit || state.recentLimit;
|
|
481
|
-
state.recentOffset = r.offset || 0;
|
|
482
|
-
renderRecent();
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async function loadFavorites() {
|
|
486
|
-
try {
|
|
487
|
-
const r = await api('GET', '/api/favorites');
|
|
488
|
-
const map = {};
|
|
489
|
-
for (const f of r.favorites || []) map[f.sessionId] = f;
|
|
490
|
-
state.favorites = map;
|
|
491
|
-
renderFavorites();
|
|
492
|
-
} catch (e) { /* ignore */ }
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function toggleFavorite(sessionId, sourceRow) {
|
|
496
|
-
const wasFav = !!state.favorites[sessionId];
|
|
497
|
-
if (wasFav) {
|
|
498
|
-
// optimistic remove
|
|
499
|
-
delete state.favorites[sessionId];
|
|
500
|
-
renderFavorites();
|
|
501
|
-
renderSessions();
|
|
502
|
-
renderRecent();
|
|
503
|
-
try { await api('DELETE', `/api/favorites/${sessionId}`); }
|
|
504
|
-
catch (e) { toast('unfavorite failed: ' + e.message, 'error'); }
|
|
505
|
-
} else {
|
|
506
|
-
// optimistic add — snapshot row's data so the favorite is meaningful
|
|
507
|
-
// even when the session later moves out of live/recent
|
|
508
|
-
const cwd = sourceRow?.dataset?.cwd || '';
|
|
509
|
-
const title = sourceRow?.dataset?.title || '';
|
|
510
|
-
const gitBranch = sourceRow?.dataset?.gitBranch || '';
|
|
511
|
-
state.favorites[sessionId] = { sessionId, cwd, title, gitBranch, addedAt: Date.now() };
|
|
512
|
-
renderFavorites();
|
|
513
|
-
renderSessions();
|
|
514
|
-
renderRecent();
|
|
515
|
-
try { await api('POST', `/api/favorites/${sessionId}`, { cwd, title, gitBranch }); }
|
|
516
|
-
catch (e) { toast('favorite failed: ' + e.message, 'error'); }
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
async function loadSnapshot() {
|
|
520
|
-
const r = await api('GET', '/api/snapshot');
|
|
521
|
-
state.snapshot = r.snapshot;
|
|
522
|
-
const h = await api('GET', '/api/snapshot/history');
|
|
523
|
-
state.history = h.history;
|
|
524
|
-
renderSnapshot();
|
|
525
|
-
}
|
|
526
|
-
async function loadWorkspaces() {
|
|
527
|
-
const r = await api('GET', '/api/workspaces');
|
|
528
|
-
state.workspaces = r.workspaces;
|
|
529
|
-
renderWorkspaces();
|
|
530
|
-
}
|
|
531
|
-
async function refreshAll() {
|
|
532
|
-
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites()]);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/* ─────────────────────────────────────────────────────────────
|
|
536
|
-
Clone progress stream (NDJSON)
|
|
537
|
-
───────────────────────────────────────────────────────────── */
|
|
538
|
-
|
|
539
|
-
function resetProgress(repoNames) {
|
|
540
|
-
const root = $('#newSessionProgress');
|
|
541
|
-
root.innerHTML = '';
|
|
542
|
-
for (const r of repoNames) {
|
|
543
|
-
const el = document.createElement('div');
|
|
544
|
-
el.className = 'progress-item';
|
|
545
|
-
el.dataset.repo = r;
|
|
546
|
-
el.innerHTML = `
|
|
547
|
-
<div class="head">
|
|
548
|
-
<span class="name">${escapeHtml(r)}</span>
|
|
549
|
-
<span class="phase">queued</span>
|
|
550
|
-
<span class="pct"></span>
|
|
551
|
-
</div>
|
|
552
|
-
<div class="progress-bar"><div class="fill"></div></div>
|
|
553
|
-
<div class="detail"></div>
|
|
554
|
-
`;
|
|
555
|
-
root.appendChild(el);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
function progressItem(repo) {
|
|
559
|
-
return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
|
|
560
|
-
}
|
|
561
|
-
function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
|
|
562
|
-
const el = progressItem(repo);
|
|
563
|
-
if (!el) return;
|
|
564
|
-
if (state) {
|
|
565
|
-
el.classList.remove('ok', 'error');
|
|
566
|
-
if (state === 'ok' || state === 'error') el.classList.add(state);
|
|
567
|
-
}
|
|
568
|
-
if (phase != null) el.querySelector('.phase').textContent = phase;
|
|
569
|
-
if (percent != null) {
|
|
570
|
-
el.querySelector('.pct').textContent = `${percent}%`;
|
|
571
|
-
el.querySelector('.fill').style.width = `${percent}%`;
|
|
572
|
-
el.querySelector('.fill').classList.remove('indeterminate');
|
|
573
|
-
}
|
|
574
|
-
if (indeterminate) {
|
|
575
|
-
el.querySelector('.fill').classList.add('indeterminate');
|
|
576
|
-
el.querySelector('.pct').textContent = '';
|
|
577
|
-
}
|
|
578
|
-
if (detail != null) el.querySelector('.detail').textContent = detail;
|
|
579
|
-
}
|
|
580
|
-
async function streamNewSession(body) {
|
|
581
|
-
const res = await fetch('/api/sessions/new', {
|
|
582
|
-
method: 'POST',
|
|
583
|
-
headers: { 'Content-Type': 'application/json' },
|
|
584
|
-
body: JSON.stringify(body),
|
|
585
|
-
});
|
|
586
|
-
if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
|
|
587
|
-
const j = await res.json();
|
|
588
|
-
throw new Error(j.error || `HTTP ${res.status}`);
|
|
589
|
-
}
|
|
590
|
-
const reader = res.body.getReader();
|
|
591
|
-
const decoder = new TextDecoder();
|
|
592
|
-
let buf = '';
|
|
593
|
-
let final = null;
|
|
594
|
-
while (true) {
|
|
595
|
-
const { done, value } = await reader.read();
|
|
596
|
-
if (done) break;
|
|
597
|
-
buf += decoder.decode(value, { stream: true });
|
|
598
|
-
const lines = buf.split('\n');
|
|
599
|
-
buf = lines.pop();
|
|
600
|
-
for (const line of lines) {
|
|
601
|
-
if (!line.trim()) continue;
|
|
602
|
-
let event;
|
|
603
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
604
|
-
handleNewSessionEvent(event);
|
|
605
|
-
if (event.type === 'done') final = event;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
if (buf.trim()) {
|
|
609
|
-
try {
|
|
610
|
-
const event = JSON.parse(buf);
|
|
611
|
-
handleNewSessionEvent(event);
|
|
612
|
-
if (event.type === 'done') final = event;
|
|
613
|
-
} catch {}
|
|
614
|
-
}
|
|
615
|
-
return final || { success: false, error: 'stream ended unexpectedly' };
|
|
616
|
-
}
|
|
617
|
-
function handleNewSessionEvent(ev) {
|
|
618
|
-
switch (ev.type) {
|
|
619
|
-
case 'workspace':
|
|
620
|
-
$('#newSessionResult').textContent =
|
|
621
|
-
`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
|
|
622
|
-
break;
|
|
623
|
-
case 'clone-start':
|
|
624
|
-
setProgress(ev.repo, { phase: 'starting', indeterminate: true });
|
|
625
|
-
break;
|
|
626
|
-
case 'clone-progress':
|
|
627
|
-
setProgress(ev.repo, {
|
|
628
|
-
phase: ev.phase,
|
|
629
|
-
percent: ev.percent,
|
|
630
|
-
detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
|
|
631
|
-
});
|
|
632
|
-
break;
|
|
633
|
-
case 'clone-end':
|
|
634
|
-
if (ev.ok) {
|
|
635
|
-
setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok' });
|
|
636
|
-
} else {
|
|
637
|
-
setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error' });
|
|
638
|
-
}
|
|
639
|
-
break;
|
|
640
|
-
case 'launched':
|
|
641
|
-
$('#newSessionResult').textContent =
|
|
642
|
-
`terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/* ─────────────────────────────────────────────────────────────
|
|
648
|
-
Wiring
|
|
649
|
-
───────────────────────────────────────────────────────────── */
|
|
650
|
-
|
|
651
|
-
function wireUp() {
|
|
652
|
-
/* sidebar */
|
|
653
|
-
$$('.nav-item').forEach((b) => {
|
|
654
|
-
b.addEventListener('click', () => selectTab(b.dataset.tab));
|
|
655
|
-
});
|
|
656
|
-
$('#collapseBtn').addEventListener('click', toggleSidebar);
|
|
657
|
-
|
|
658
|
-
/* hash routing */
|
|
659
|
-
const hash = location.hash.slice(1);
|
|
660
|
-
if (TAB_HEADINGS[hash]) state.activeTab = hash;
|
|
661
|
-
|
|
662
|
-
$('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
|
|
663
|
-
|
|
664
|
-
/* delegated star toggle across all tables */
|
|
665
|
-
for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
|
|
666
|
-
$(tableSel).addEventListener('click', (ev) => {
|
|
667
|
-
const starBtn = ev.target.closest('button[data-star]');
|
|
668
|
-
if (!starBtn) return;
|
|
669
|
-
ev.stopPropagation();
|
|
670
|
-
const sessionId = starBtn.dataset.star;
|
|
671
|
-
const row = starBtn.closest('tr');
|
|
672
|
-
toggleFavorite(sessionId, row);
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/* favorites table delegated actions (focus / resume / continue) */
|
|
677
|
-
$('#favoritesTable').addEventListener('click', async (ev) => {
|
|
678
|
-
const focusBtn = ev.target.closest('button[data-focus]');
|
|
679
|
-
if (focusBtn) {
|
|
680
|
-
const sessionId = focusBtn.dataset.focus;
|
|
681
|
-
focusBtn.disabled = true;
|
|
682
|
-
try {
|
|
683
|
-
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
684
|
-
if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
|
|
685
|
-
else toast(`focus blocked or not running`, 'error');
|
|
686
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
687
|
-
finally { focusBtn.disabled = false; }
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
const resumeBtn = ev.target.closest('button[data-resume], button[data-continue]');
|
|
691
|
-
if (!resumeBtn) return;
|
|
692
|
-
const sessionId = resumeBtn.dataset.resume || resumeBtn.dataset.continue;
|
|
693
|
-
const cwd = resumeBtn.dataset.cwd;
|
|
694
|
-
if (!cwd) return toast('no cwd for this favorite', 'error');
|
|
695
|
-
resumeBtn.disabled = true;
|
|
696
|
-
try {
|
|
697
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
698
|
-
toast(`opening wt · ${sessionId.slice(0, 8)}…`);
|
|
699
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
700
|
-
finally { resumeBtn.disabled = false; }
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
/* inline finder button on Sessions tab — same handler as the sidebar one */
|
|
704
|
-
const inlineFinder = $('#finderInlineBtn');
|
|
705
|
-
if (inlineFinder) {
|
|
706
|
-
inlineFinder.onclick = () => $('#finderBtn').click();
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/* recent pagination */
|
|
710
|
-
$('#recentPrevBtn').onclick = () => {
|
|
711
|
-
state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
|
|
712
|
-
loadRecent().catch(() => {});
|
|
713
|
-
};
|
|
714
|
-
$('#recentNextBtn').onclick = () => {
|
|
715
|
-
state.recentOffset = state.recentOffset + state.recentLimit;
|
|
716
|
-
loadRecent().catch(() => {});
|
|
717
|
-
};
|
|
718
|
-
$('#recentPageSize').onchange = (e) => {
|
|
719
|
-
state.recentLimit = Math.max(1, Number(e.target.value) || 15);
|
|
720
|
-
state.recentOffset = 0;
|
|
721
|
-
loadRecent().catch(() => {});
|
|
722
|
-
};
|
|
723
|
-
|
|
724
|
-
/* live sessions actions */
|
|
725
|
-
$('#sessionsTable').addEventListener('click', async (ev) => {
|
|
726
|
-
if (ev.target.closest('button[data-star]')) return;
|
|
727
|
-
const focusBtn = ev.target.closest('button[data-focus]');
|
|
728
|
-
if (focusBtn) {
|
|
729
|
-
const sessionId = focusBtn.dataset.focus;
|
|
730
|
-
focusBtn.disabled = true;
|
|
731
|
-
try {
|
|
732
|
-
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
733
|
-
if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
|
|
734
|
-
else if (r.ok) toast(`window found, focus blocked (${r.windowProcess})`, 'error');
|
|
735
|
-
else toast(`no window for pid · ${(r.chain || []).map((c) => c.name).join('→')}`, 'error');
|
|
736
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
737
|
-
finally { focusBtn.disabled = false; }
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
const resumeBtn = ev.target.closest('button[data-resume]');
|
|
741
|
-
if (!resumeBtn) return;
|
|
742
|
-
const sessionId = resumeBtn.dataset.resume;
|
|
743
|
-
const cwd = resumeBtn.dataset.cwd;
|
|
744
|
-
resumeBtn.disabled = true;
|
|
745
|
-
try {
|
|
746
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
747
|
-
toast(`opening wt · ${sessionId.slice(0, 8)}…`);
|
|
748
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
749
|
-
finally { resumeBtn.disabled = false; }
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
/* recent continue */
|
|
753
|
-
$('#recentTable').addEventListener('click', async (ev) => {
|
|
754
|
-
if (ev.target.closest('button[data-star]')) return;
|
|
755
|
-
const btn = ev.target.closest('button[data-continue]');
|
|
756
|
-
if (!btn) return;
|
|
757
|
-
const sessionId = btn.dataset.continue;
|
|
758
|
-
const cwd = btn.dataset.cwd;
|
|
759
|
-
btn.disabled = true;
|
|
760
|
-
try {
|
|
761
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
762
|
-
toast(`continuing · ${sessionId.slice(0, 8)}…`);
|
|
763
|
-
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
764
|
-
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
765
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
766
|
-
finally { btn.disabled = false; }
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
/* finder */
|
|
770
|
-
$('#finderBtn').onclick = async () => {
|
|
771
|
-
try {
|
|
772
|
-
await api('POST', '/api/sessions/finder');
|
|
773
|
-
toast('finder session launching in a new wt window');
|
|
774
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
/* snapshot */
|
|
778
|
-
$('#snapshotSaveBtn').onclick = async () => {
|
|
779
|
-
try {
|
|
780
|
-
const r = await api('POST', '/api/snapshot');
|
|
781
|
-
state.snapshot = r.snapshot;
|
|
782
|
-
const h = await api('GET', '/api/snapshot/history');
|
|
783
|
-
state.history = h.history;
|
|
784
|
-
renderSnapshot();
|
|
785
|
-
toast(`saved · ${r.snapshot.sessions.length} session(s)`);
|
|
786
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
787
|
-
};
|
|
788
|
-
$('#snapshotRestoreBtn').onclick = async () => {
|
|
789
|
-
const snap = state.snapshot;
|
|
790
|
-
if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
|
|
791
|
-
if (!confirm(`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`)) return;
|
|
792
|
-
try {
|
|
793
|
-
const r = await api('POST', '/api/snapshot/restore');
|
|
794
|
-
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
795
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
796
|
-
};
|
|
797
|
-
$('#historyRestoreBtn').onclick = async () => {
|
|
798
|
-
const file = $('#historySelect').value;
|
|
799
|
-
if (!file) return toast('pick a history snapshot first', 'error');
|
|
800
|
-
if (!confirm(`Restore from ${file}?`)) return;
|
|
801
|
-
try {
|
|
802
|
-
const r = await api('POST', '/api/snapshot/restore', { file });
|
|
803
|
-
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
804
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
/* new session */
|
|
808
|
-
$('#newSessionBtn').onclick = async () => {
|
|
809
|
-
const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
|
|
810
|
-
if (repos.length === 0) return toast('select at least one repo', 'error');
|
|
811
|
-
const workspace = $('#workspaceSelect').value || undefined;
|
|
812
|
-
const btn = $('#newSessionBtn');
|
|
813
|
-
btn.disabled = true;
|
|
814
|
-
$('#newSessionResult').textContent = '';
|
|
815
|
-
resetProgress(repos);
|
|
816
|
-
try {
|
|
817
|
-
const result = await streamNewSession({ repos, workspace });
|
|
818
|
-
if (result.success) {
|
|
819
|
-
const ws = result.workspace;
|
|
820
|
-
const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
821
|
-
$('#newSessionResult').textContent =
|
|
822
|
-
`launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
|
|
823
|
-
toast(`launched · ${ws.name}`);
|
|
824
|
-
} else {
|
|
825
|
-
$('#newSessionResult').textContent = `error: ${result.error}`;
|
|
826
|
-
toast(result.error || 'new session failed', 'error');
|
|
827
|
-
}
|
|
828
|
-
await loadWorkspaces();
|
|
829
|
-
} catch (e) {
|
|
830
|
-
$('#newSessionResult').textContent = `error: ${e.message}`;
|
|
831
|
-
toast(e.message, 'error');
|
|
832
|
-
} finally { btn.disabled = false; }
|
|
833
|
-
};
|
|
834
|
-
|
|
835
|
-
/* config save */
|
|
836
|
-
$('#saveConfigBtn').onclick = async () => {
|
|
837
|
-
const next = readConfigFromForm();
|
|
838
|
-
try {
|
|
839
|
-
const cfg = await api('PUT', '/api/config', next);
|
|
840
|
-
state.config = cfg;
|
|
841
|
-
renderConfig();
|
|
842
|
-
renderRepoPicker();
|
|
843
|
-
renderHeaderStatus();
|
|
844
|
-
$('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
|
|
845
|
-
toast('config saved');
|
|
846
|
-
await loadWorkspaces();
|
|
847
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
848
|
-
};
|
|
849
|
-
$('#addRepoBtn').onclick = () => {
|
|
850
|
-
state.config.repos.push({ name: '', url: '', defaultSelected: false });
|
|
851
|
-
renderConfig();
|
|
852
|
-
};
|
|
853
|
-
$('#reposTable').addEventListener('click', (ev) => {
|
|
854
|
-
const rm = ev.target.closest('button[data-remove-repo]');
|
|
855
|
-
if (!rm) return;
|
|
856
|
-
const idx = Number(rm.dataset.removeRepo);
|
|
857
|
-
state.config.repos.splice(idx, 1);
|
|
858
|
-
renderConfig();
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function startAutoRefresh() {
|
|
863
|
-
if (state.autoTimer) clearInterval(state.autoTimer);
|
|
864
|
-
state.autoTimer = setInterval(() => {
|
|
865
|
-
loadSessions().catch(() => {});
|
|
866
|
-
loadRecent().catch(() => {});
|
|
867
|
-
loadSnapshot().catch(() => {});
|
|
868
|
-
}, 5000);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// Re-render favorites when sessions update so live status of favorited rows refreshes.
|
|
872
|
-
function reRenderFavoritesIfNeeded() {
|
|
873
|
-
if (Object.keys(state.favorites).length === 0) return;
|
|
874
|
-
renderFavorites();
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/* ─────────────────────────────────────────────────────────────
|
|
878
|
-
Boot
|
|
879
|
-
───────────────────────────────────────────────────────────── */
|
|
880
|
-
|
|
881
|
-
(async () => {
|
|
882
|
-
restoreSidebar();
|
|
883
|
-
wireUp();
|
|
884
|
-
try {
|
|
885
|
-
await loadConfig();
|
|
886
|
-
await refreshAll();
|
|
887
|
-
selectTab(state.activeTab);
|
|
888
|
-
startAutoRefresh();
|
|
889
|
-
tickClock();
|
|
890
|
-
state.clockTimer = setInterval(tickClock, 1000);
|
|
891
|
-
} catch (e) {
|
|
892
|
-
toast('initial load failed · ' + e.message, 'error');
|
|
893
|
-
}
|
|
894
|
-
})();
|