@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
package/public/app.js
DELETED
|
@@ -1,1353 +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: 10,
|
|
18
|
-
sessionsOffset: 0,
|
|
19
|
-
sessionsLimit: 10,
|
|
20
|
-
favoritesOffset: 0,
|
|
21
|
-
favoritesLimit: 10,
|
|
22
|
-
cardFolded: { favorites: false, sessions: false, recent: false },
|
|
23
|
-
configDirty: false,
|
|
24
|
-
serverHealth: { state: 'connecting' },
|
|
25
|
-
favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
|
|
26
|
-
labels: {}, // { sessionId: customLabel } — user-defined title overrides
|
|
27
|
-
workspaces: [],
|
|
28
|
-
snapshot: null,
|
|
29
|
-
history: [],
|
|
30
|
-
autoTimer: null,
|
|
31
|
-
clockTimer: null,
|
|
32
|
-
activeTab: 'sessions',
|
|
33
|
-
// Tables that have already had their first render — used to suppress the
|
|
34
|
-
// row stagger animation on subsequent re-renders so 5s auto-refresh
|
|
35
|
-
// doesn't strobe.
|
|
36
|
-
renderedTables: new Set(),
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const TAB_HEADINGS = {
|
|
40
|
-
sessions: { title: 'Sessions', subtitle: 'Live and recently-closed Claude Code sessions on this machine.' },
|
|
41
|
-
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace, or restore from snapshot.' },
|
|
42
|
-
configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/* ── API ── */
|
|
46
|
-
async function api(method, url, body) {
|
|
47
|
-
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
48
|
-
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
49
|
-
const r = await fetch(url, opts);
|
|
50
|
-
const text = await r.text();
|
|
51
|
-
let json;
|
|
52
|
-
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
53
|
-
if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
|
|
54
|
-
return json;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/* ── toast ── */
|
|
58
|
-
const toastEl = $('#toast');
|
|
59
|
-
let toastT;
|
|
60
|
-
function toast(msg, kind = 'ok') {
|
|
61
|
-
toastEl.textContent = msg;
|
|
62
|
-
toastEl.className = `toast show ${kind}`;
|
|
63
|
-
clearTimeout(toastT);
|
|
64
|
-
toastT = setTimeout(() => toastEl.classList.remove('show'), 3200);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/* ── fmt ── */
|
|
68
|
-
function fmtTime(ms) {
|
|
69
|
-
if (!ms) return '—';
|
|
70
|
-
return new Date(ms).toLocaleString(undefined, { hour12: false });
|
|
71
|
-
}
|
|
72
|
-
function fmtAgo(ms) {
|
|
73
|
-
if (!ms) return '—';
|
|
74
|
-
const sec = Math.floor((Date.now() - ms) / 1000);
|
|
75
|
-
if (sec < 60) return `${sec}s`;
|
|
76
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
77
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
|
|
78
|
-
return `${Math.floor(sec / 86400)}d`;
|
|
79
|
-
}
|
|
80
|
-
function escapeHtml(s) {
|
|
81
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
|
82
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
83
|
-
}[c]));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/* Mark a table as already-rendered so animations don't replay on
|
|
87
|
-
subsequent updates. Call after the first innerHTML population. */
|
|
88
|
-
/* Pagination footer helper — sets visibility, page numbers, button states.
|
|
89
|
-
Caller wires up the click/change handlers separately (once). */
|
|
90
|
-
function setPaginationFooter({ footerId, prevId, nextId, pageNumId, pageTotalId, totalId, total, offset, limit }) {
|
|
91
|
-
const footer = $(`#${footerId}`);
|
|
92
|
-
if (!footer) return;
|
|
93
|
-
if (total <= limit) {
|
|
94
|
-
footer.hidden = true;
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
footer.hidden = false;
|
|
98
|
-
const pageNum = Math.floor(offset / limit) + 1;
|
|
99
|
-
const pageTotal = Math.max(1, Math.ceil(total / limit));
|
|
100
|
-
$(`#${pageNumId}`).textContent = pageNum;
|
|
101
|
-
$(`#${pageTotalId}`).textContent = pageTotal;
|
|
102
|
-
$(`#${totalId}`).textContent = total;
|
|
103
|
-
$(`#${prevId}`).disabled = offset === 0;
|
|
104
|
-
$(`#${nextId}`).disabled = offset + limit >= total;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function markRendered(tableId) {
|
|
108
|
-
const tb = document.querySelector(`#${tableId} tbody`);
|
|
109
|
-
if (!tb) return;
|
|
110
|
-
if (state.renderedTables.has(tableId)) {
|
|
111
|
-
tb.classList.add('no-anim');
|
|
112
|
-
} else {
|
|
113
|
-
state.renderedTables.add(tableId);
|
|
114
|
-
// first render: animation runs. We schedule no-anim for next paint
|
|
115
|
-
// so the very next re-render doesn't restage.
|
|
116
|
-
requestAnimationFrame(() => {
|
|
117
|
-
requestAnimationFrame(() => tb.classList.add('no-anim'));
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const STAR_SVG_OUTLINE =
|
|
123
|
-
`<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">
|
|
124
|
-
<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"/>
|
|
125
|
-
</svg>`;
|
|
126
|
-
const STAR_SVG_FILLED =
|
|
127
|
-
`<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">
|
|
128
|
-
<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"/>
|
|
129
|
-
</svg>`;
|
|
130
|
-
|
|
131
|
-
function starButtonHtml(sessionId, isFav) {
|
|
132
|
-
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>`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const PENCIL_SVG =
|
|
136
|
-
`<svg class="pencil-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
137
|
-
<path d="M12 20h9"/>
|
|
138
|
-
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
139
|
-
</svg>`;
|
|
140
|
-
function renameButtonHtml(sessionId, hasLabel) {
|
|
141
|
-
return `<button class="rename-btn ${hasLabel ? 'has-label' : ''}" data-rename="${escapeHtml(sessionId)}" title="${hasLabel ? 'rename · custom label set' : 'rename'}" aria-label="rename">${PENCIL_SVG}</button>`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Compose the displayed title: user override (label) takes precedence over
|
|
145
|
-
// claude's ai-title. Falls back to "(no title)" if both empty.
|
|
146
|
-
function displayTitle(sessionId, fallback) {
|
|
147
|
-
return state.labels[sessionId] || fallback || '(no title)';
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/* ─────────────────────────────────────────────────────────────
|
|
151
|
-
Sidebar — tabs + collapse
|
|
152
|
-
───────────────────────────────────────────────────────────── */
|
|
153
|
-
|
|
154
|
-
function selectTab(name) {
|
|
155
|
-
if (!TAB_HEADINGS[name]) name = 'sessions';
|
|
156
|
-
state.activeTab = name;
|
|
157
|
-
$$('.nav-item').forEach((b) => {
|
|
158
|
-
b.setAttribute('aria-selected', b.dataset.tab === name ? 'true' : 'false');
|
|
159
|
-
});
|
|
160
|
-
$$('.tab-panel').forEach((p) => {
|
|
161
|
-
if (p.dataset.panel === name) p.setAttribute('data-active', '');
|
|
162
|
-
else p.removeAttribute('data-active');
|
|
163
|
-
});
|
|
164
|
-
const h = TAB_HEADINGS[name];
|
|
165
|
-
$('#pageTitle').textContent = h.title;
|
|
166
|
-
$('#pageSubtitle').textContent = h.subtitle;
|
|
167
|
-
if (location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function toggleSidebar() {
|
|
171
|
-
const sb = $('#sidebar');
|
|
172
|
-
const collapsed = sb.getAttribute('data-collapsed') === 'true';
|
|
173
|
-
sb.setAttribute('data-collapsed', collapsed ? 'false' : 'true');
|
|
174
|
-
localStorage.setItem('ccsm.sidebar-collapsed', collapsed ? 'false' : 'true');
|
|
175
|
-
}
|
|
176
|
-
function restoreSidebar() {
|
|
177
|
-
const v = localStorage.getItem('ccsm.sidebar-collapsed');
|
|
178
|
-
if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/* ── Config dirty state ── */
|
|
182
|
-
function setConfigDirty(dirty) {
|
|
183
|
-
state.configDirty = dirty;
|
|
184
|
-
const banner = document.getElementById('configDirtyBanner');
|
|
185
|
-
if (banner) banner.hidden = !dirty;
|
|
186
|
-
const nav = document.querySelector('.nav-item[data-tab="configure"]');
|
|
187
|
-
if (nav) nav.classList.toggle('has-changes', dirty);
|
|
188
|
-
const saveBtn = document.getElementById('saveConfigBtn');
|
|
189
|
-
if (saveBtn) saveBtn.classList.toggle('is-dirty', dirty);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/* ── Card fold ── */
|
|
193
|
-
function toggleCardFold(key) {
|
|
194
|
-
const card = document.querySelector(`.card[data-fold-key="${key}"]`);
|
|
195
|
-
if (!card) return;
|
|
196
|
-
const next = !state.cardFolded[key];
|
|
197
|
-
state.cardFolded[key] = next;
|
|
198
|
-
if (next) card.setAttribute('data-collapsed', '');
|
|
199
|
-
else card.removeAttribute('data-collapsed');
|
|
200
|
-
localStorage.setItem(`ccsm.fold.${key}`, next ? '1' : '0');
|
|
201
|
-
}
|
|
202
|
-
function restoreCardFolds() {
|
|
203
|
-
for (const key of ['favorites', 'sessions', 'recent']) {
|
|
204
|
-
const v = localStorage.getItem(`ccsm.fold.${key}`);
|
|
205
|
-
if (v === '1') {
|
|
206
|
-
state.cardFolded[key] = true;
|
|
207
|
-
const card = document.querySelector(`.card[data-fold-key="${key}"]`);
|
|
208
|
-
if (card) card.setAttribute('data-collapsed', '');
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/* ─────────────────────────────────────────────────────────────
|
|
214
|
-
Render: sessions (live)
|
|
215
|
-
───────────────────────────────────────────────────────────── */
|
|
216
|
-
|
|
217
|
-
function renderSessions() {
|
|
218
|
-
const tb = $('#sessionsTable tbody');
|
|
219
|
-
tb.innerHTML = '';
|
|
220
|
-
// clamp offset if data shrunk
|
|
221
|
-
if (state.sessionsOffset >= state.sessions.length) {
|
|
222
|
-
state.sessionsOffset = Math.max(0, Math.floor((state.sessions.length - 1) / state.sessionsLimit) * state.sessionsLimit);
|
|
223
|
-
}
|
|
224
|
-
const slice = state.sessions.slice(state.sessionsOffset, state.sessionsOffset + state.sessionsLimit);
|
|
225
|
-
for (const s of slice) {
|
|
226
|
-
const isFav = !!state.favorites[s.sessionId];
|
|
227
|
-
const hasLabel = !!state.labels[s.sessionId];
|
|
228
|
-
const shown = displayTitle(s.sessionId, s.title);
|
|
229
|
-
const tooltip = hasLabel ? `${shown}\n(original: ${s.title || '—'})` : shown;
|
|
230
|
-
const tr = document.createElement('tr');
|
|
231
|
-
tr.innerHTML = `
|
|
232
|
-
<td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
|
|
233
|
-
<td>
|
|
234
|
-
<div class="title-cell">
|
|
235
|
-
<div class="title-row">
|
|
236
|
-
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
237
|
-
${renameButtonHtml(s.sessionId, hasLabel)}
|
|
238
|
-
${starButtonHtml(s.sessionId, isFav)}
|
|
239
|
-
</div>
|
|
240
|
-
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
|
|
241
|
-
</div>
|
|
242
|
-
</td>
|
|
243
|
-
<td><div class="path-cell" title="${escapeHtml(s.cwd)}">${escapeHtml(s.cwd)}</div></td>
|
|
244
|
-
<td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
|
|
245
|
-
<td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
|
|
246
|
-
<td class="num">${escapeHtml(String(s.pid))}</td>
|
|
247
|
-
<td>
|
|
248
|
-
<div class="row-actions">
|
|
249
|
-
<button class="action small primary" data-focus="${escapeHtml(s.sessionId)}" title="raise the wt window already running this session">Focus</button>
|
|
250
|
-
<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>
|
|
251
|
-
</div>
|
|
252
|
-
</td>
|
|
253
|
-
`;
|
|
254
|
-
tr.dataset.cwd = s.cwd;
|
|
255
|
-
tr.dataset.title = s.title || '';
|
|
256
|
-
tb.appendChild(tr);
|
|
257
|
-
}
|
|
258
|
-
$('#sessionsEmpty').hidden = state.sessions.length > 0;
|
|
259
|
-
const ts = new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
260
|
-
$('#sessionsMeta').textContent = state.sessions.length
|
|
261
|
-
? `${state.sessions.length} live · refreshed ${ts}`
|
|
262
|
-
: 'no live sessions';
|
|
263
|
-
$('#navCount-sessions').textContent = state.sessions.length;
|
|
264
|
-
setPaginationFooter({
|
|
265
|
-
footerId: 'sessionsPagination', prevId: 'sessPrevBtn', nextId: 'sessNextBtn',
|
|
266
|
-
pageNumId: 'sessPageNum', pageTotalId: 'sessPageTotal', totalId: 'sessTotal',
|
|
267
|
-
total: state.sessions.length, offset: state.sessionsOffset, limit: state.sessionsLimit,
|
|
268
|
-
});
|
|
269
|
-
markRendered('sessionsTable');
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/* ─────────────────────────────────────────────────────────────
|
|
273
|
-
Render: recently closed
|
|
274
|
-
───────────────────────────────────────────────────────────── */
|
|
275
|
-
|
|
276
|
-
function renderRecent() {
|
|
277
|
-
const tb = $('#recentTable tbody');
|
|
278
|
-
tb.innerHTML = '';
|
|
279
|
-
const recent = state.recent || [];
|
|
280
|
-
for (const s of recent) {
|
|
281
|
-
const isFav = !!state.favorites[s.sessionId];
|
|
282
|
-
const hasLabel = !!state.labels[s.sessionId];
|
|
283
|
-
const shown = displayTitle(s.sessionId, s.title);
|
|
284
|
-
const tooltip = hasLabel ? `${shown}\n(original: ${s.title || '—'})` : shown;
|
|
285
|
-
const tr = document.createElement('tr');
|
|
286
|
-
tr.innerHTML = `
|
|
287
|
-
<td>
|
|
288
|
-
<div class="title-cell">
|
|
289
|
-
<div class="title-row">
|
|
290
|
-
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
291
|
-
${renameButtonHtml(s.sessionId, hasLabel)}
|
|
292
|
-
${starButtonHtml(s.sessionId, isFav)}
|
|
293
|
-
</div>
|
|
294
|
-
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
|
|
295
|
-
</div>
|
|
296
|
-
</td>
|
|
297
|
-
<td><div class="path-cell" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
|
|
298
|
-
<td>${s.gitBranch ? `<span class="branch-tag">${escapeHtml(s.gitBranch)}</span>` : '<span class="muted-text">—</span>'}</td>
|
|
299
|
-
<td class="num" title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
|
|
300
|
-
<td class="num" title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
|
|
301
|
-
<td>
|
|
302
|
-
<div class="row-actions">
|
|
303
|
-
<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>
|
|
304
|
-
</div>
|
|
305
|
-
</td>
|
|
306
|
-
`;
|
|
307
|
-
tr.dataset.cwd = s.cwd || '';
|
|
308
|
-
tr.dataset.title = s.title || '';
|
|
309
|
-
tr.dataset.gitBranch = s.gitBranch || '';
|
|
310
|
-
tb.appendChild(tr);
|
|
311
|
-
}
|
|
312
|
-
$('#recentEmpty').hidden = recent.length > 0;
|
|
313
|
-
$('#recentMeta').textContent = state.recentTotal
|
|
314
|
-
? `${state.recentTotal} total · sorted by jsonl mtime, excluding live`
|
|
315
|
-
: 'no recent sessions';
|
|
316
|
-
setPaginationFooter({
|
|
317
|
-
footerId: 'recentPagination', prevId: 'recentPrevBtn', nextId: 'recentNextBtn',
|
|
318
|
-
pageNumId: 'recentPageNum', pageTotalId: 'recentPageTotal', totalId: 'recentTotal',
|
|
319
|
-
total: state.recentTotal, offset: state.recentOffset, limit: state.recentLimit,
|
|
320
|
-
});
|
|
321
|
-
markRendered('recentTable');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/* ─────────────────────────────────────────────────────────────
|
|
325
|
-
Render: favorites
|
|
326
|
-
───────────────────────────────────────────────────────────── */
|
|
327
|
-
function renderFavorites() {
|
|
328
|
-
const tb = $('#favoritesTable tbody');
|
|
329
|
-
tb.innerHTML = '';
|
|
330
|
-
const fullList = Object.values(state.favorites).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
|
331
|
-
if (state.favoritesOffset >= fullList.length) {
|
|
332
|
-
state.favoritesOffset = Math.max(0, Math.floor((fullList.length - 1) / state.favoritesLimit) * state.favoritesLimit);
|
|
333
|
-
}
|
|
334
|
-
const list = fullList.slice(state.favoritesOffset, state.favoritesOffset + state.favoritesLimit);
|
|
335
|
-
for (const f of list) {
|
|
336
|
-
const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
|
|
337
|
-
const title = liveMatch?.title || f.title;
|
|
338
|
-
const cwd = liveMatch?.cwd || f.cwd;
|
|
339
|
-
const branch = f.gitBranch;
|
|
340
|
-
const actions = liveMatch
|
|
341
|
-
? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
|
|
342
|
-
<button class="action small" data-resume="${escapeHtml(f.sessionId)}" data-cwd="${escapeHtml(cwd)}" title="claude --resume in a fresh wt window">Resume new ↗</button>`
|
|
343
|
-
: `<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>`;
|
|
344
|
-
const hasLabel = !!state.labels[f.sessionId];
|
|
345
|
-
const shown = displayTitle(f.sessionId, title);
|
|
346
|
-
const tooltip = hasLabel ? `${shown}\n(original: ${title || '—'})` : shown;
|
|
347
|
-
const tr = document.createElement('tr');
|
|
348
|
-
tr.innerHTML = `
|
|
349
|
-
<td>
|
|
350
|
-
<div class="title-cell">
|
|
351
|
-
<div class="title-row">
|
|
352
|
-
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
353
|
-
${renameButtonHtml(f.sessionId, hasLabel)}
|
|
354
|
-
${starButtonHtml(f.sessionId, true)}
|
|
355
|
-
</div>
|
|
356
|
-
<div class="secondary" title="${escapeHtml(f.sessionId)}">
|
|
357
|
-
${escapeHtml(f.sessionId.slice(0, 8))}${liveMatch ? ` · <span style="color:var(--green);">live</span>` : ''}
|
|
358
|
-
</div>
|
|
359
|
-
</div>
|
|
360
|
-
</td>
|
|
361
|
-
<td><div class="path-cell" title="${escapeHtml(cwd || '')}">${escapeHtml(cwd || '')}</div></td>
|
|
362
|
-
<td>${branch ? `<span class="branch-tag">${escapeHtml(branch)}</span>` : '<span class="muted-text">—</span>'}</td>
|
|
363
|
-
<td class="num" title="${escapeHtml(fmtTime(f.addedAt))}">${escapeHtml(fmtAgo(f.addedAt))}</td>
|
|
364
|
-
<td><div class="row-actions">${actions}</div></td>
|
|
365
|
-
`;
|
|
366
|
-
tr.dataset.cwd = cwd || '';
|
|
367
|
-
tr.dataset.title = title || '';
|
|
368
|
-
tb.appendChild(tr);
|
|
369
|
-
}
|
|
370
|
-
const total = fullList.length;
|
|
371
|
-
$('#favoritesEmpty').style.display = total === 0 ? 'block' : 'none';
|
|
372
|
-
$('#favoritesTable').style.display = total === 0 ? 'none' : 'table';
|
|
373
|
-
$('#favoritesMeta').textContent = total
|
|
374
|
-
? `${total} pinned`
|
|
375
|
-
: 'click ☆ on any row to pin sessions here';
|
|
376
|
-
setPaginationFooter({
|
|
377
|
-
footerId: 'favoritesPagination', prevId: 'favPrevBtn', nextId: 'favNextBtn',
|
|
378
|
-
pageNumId: 'favPageNum', pageTotalId: 'favPageTotal', totalId: 'favTotal',
|
|
379
|
-
total, offset: state.favoritesOffset, limit: state.favoritesLimit,
|
|
380
|
-
});
|
|
381
|
-
markRendered('favoritesTable');
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/* ─────────────────────────────────────────────────────────────
|
|
385
|
-
Render: snapshot
|
|
386
|
-
───────────────────────────────────────────────────────────── */
|
|
387
|
-
|
|
388
|
-
function renderSnapshot() {
|
|
389
|
-
const snap = state.snapshot;
|
|
390
|
-
if (!snap) {
|
|
391
|
-
$('#snapshotMeta').textContent = 'no snapshot saved yet';
|
|
392
|
-
$('#snapshotPreview').textContent = '';
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
$('#snapshotMeta').textContent =
|
|
396
|
-
`${snap.sessions.length} session(s) · taken ${fmtAgo(snap.takenAt)} ago (${fmtTime(snap.takenAt)})`;
|
|
397
|
-
$('#snapshotPreview').textContent =
|
|
398
|
-
snap.sessions.map((s) =>
|
|
399
|
-
`${(s.title || s.sessionId.slice(0, 8)).padEnd(44).slice(0, 44)} ${s.cwd}`
|
|
400
|
-
).join('\n');
|
|
401
|
-
|
|
402
|
-
const sel = $('#historySelect');
|
|
403
|
-
sel.innerHTML = '<option value="">history…</option>' +
|
|
404
|
-
state.history.map((h) =>
|
|
405
|
-
`<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json', ''))}</option>`
|
|
406
|
-
).join('');
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/* ─────────────────────────────────────────────────────────────
|
|
410
|
-
Render: workspaces
|
|
411
|
-
───────────────────────────────────────────────────────────── */
|
|
412
|
-
|
|
413
|
-
function renderWorkspaces() {
|
|
414
|
-
const grid = $('#workspaceList');
|
|
415
|
-
grid.innerHTML = '';
|
|
416
|
-
if (state.workspaces.length === 0) {
|
|
417
|
-
grid.innerHTML = '<div class="empty">No workspaces yet — the first launch will create one.</div>';
|
|
418
|
-
}
|
|
419
|
-
for (const w of state.workspaces) {
|
|
420
|
-
const repos = w.repos.map((r) =>
|
|
421
|
-
`<span class="ws-repo ${r.cloned ? 'cloned' : ''}" title="${escapeHtml(r.url)}">${escapeHtml(r.name)}${r.cloned ? ' ✓' : ''}</span>`
|
|
422
|
-
).join('');
|
|
423
|
-
const card = document.createElement('div');
|
|
424
|
-
card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
|
|
425
|
-
card.innerHTML = `
|
|
426
|
-
<div class="ws-head">
|
|
427
|
-
<div class="ws-name">${escapeHtml(w.name)}</div>
|
|
428
|
-
<span class="ws-tag">${w.inUse ? `in use × ${w.sessionsHere.length}` : 'free'}</span>
|
|
429
|
-
</div>
|
|
430
|
-
<div class="ws-path">${escapeHtml(w.path)}</div>
|
|
431
|
-
<div class="ws-repos">${repos}</div>
|
|
432
|
-
`;
|
|
433
|
-
grid.appendChild(card);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const opts = '<option value="">auto — find or create unused</option>' +
|
|
437
|
-
state.workspaces.filter((w) => !w.inUse).map((w) =>
|
|
438
|
-
`<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
|
|
439
|
-
).join('');
|
|
440
|
-
for (const id of ['workspaceSelect', 'modalWorkspaceSelect']) {
|
|
441
|
-
const el = document.getElementById(id);
|
|
442
|
-
if (el) el.innerHTML = opts;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/* ─────────────────────────────────────────────────────────────
|
|
449
|
-
Render: repo picker
|
|
450
|
-
───────────────────────────────────────────────────────────── */
|
|
451
|
-
|
|
452
|
-
function renderRepoPicker() {
|
|
453
|
-
const repos = state.config?.repos || [];
|
|
454
|
-
for (const rootId of ['repoPicker', 'modalRepoPicker']) {
|
|
455
|
-
const root = document.getElementById(rootId);
|
|
456
|
-
if (!root) continue;
|
|
457
|
-
if (repos.length === 0) {
|
|
458
|
-
root.innerHTML = '<span class="muted-text">no repos configured · use <strong>+ Add repo</strong> below</span>';
|
|
459
|
-
continue;
|
|
460
|
-
}
|
|
461
|
-
root.innerHTML = '';
|
|
462
|
-
for (const r of repos) {
|
|
463
|
-
const chip = document.createElement('label');
|
|
464
|
-
chip.className = 'chip' + (r.defaultSelected ? ' checked' : '');
|
|
465
|
-
chip.innerHTML = `<input type="checkbox" data-repo="${escapeHtml(r.name)}" ${r.defaultSelected ? 'checked' : ''}/>${escapeHtml(r.name)}`;
|
|
466
|
-
chip.querySelector('input').addEventListener('change', (e) => {
|
|
467
|
-
chip.classList.toggle('checked', e.target.checked);
|
|
468
|
-
});
|
|
469
|
-
root.appendChild(chip);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/* Renders the inline repos editor inside the modal. Uses state.config.repos
|
|
475
|
-
directly (writes back through the same Configure save). */
|
|
476
|
-
function renderModalReposEditor() {
|
|
477
|
-
const tb = document.querySelector('#modalReposTable tbody');
|
|
478
|
-
if (!tb) return;
|
|
479
|
-
tb.innerHTML = '';
|
|
480
|
-
(state.config?.repos || []).forEach((r, idx) => {
|
|
481
|
-
const tr = document.createElement('tr');
|
|
482
|
-
tr.innerHTML = `
|
|
483
|
-
<td><input type="text" value="${escapeHtml(r.name)}" data-modal-field="name" data-idx="${idx}" /></td>
|
|
484
|
-
<td><input type="text" value="${escapeHtml(r.url)}" data-modal-field="url" data-idx="${idx}" /></td>
|
|
485
|
-
<td class="num"><input type="checkbox" data-modal-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
|
|
486
|
-
<td><div class="row-actions"><button class="action tiny danger" data-modal-remove="${idx}">Remove</button></div></td>
|
|
487
|
-
`;
|
|
488
|
-
tb.appendChild(tr);
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/* ─────────────────────────────────────────────────────────────
|
|
493
|
-
Render: config form
|
|
494
|
-
───────────────────────────────────────────────────────────── */
|
|
495
|
-
|
|
496
|
-
function renderConfig() {
|
|
497
|
-
if (!state.config) return;
|
|
498
|
-
$('#cfgPort').value = state.config.port;
|
|
499
|
-
$('#cfgWorkDir').value = state.config.workDir;
|
|
500
|
-
$('#cfgInterval').value = state.config.snapshotIntervalMs;
|
|
501
|
-
$('#cfgKeep').value = state.config.snapshotHistoryKeep;
|
|
502
|
-
$('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
|
|
503
|
-
$('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
|
|
504
|
-
$('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
|
|
505
|
-
$('#cfgFocusCenter').checked = state.config.focusMovesToCenter === true;
|
|
506
|
-
$('#cfgBrowserMode').value =
|
|
507
|
-
state.config.browserMode ||
|
|
508
|
-
(state.config.autoOpenBrowser === false ? 'none' : 'app');
|
|
509
|
-
|
|
510
|
-
const termSel = $('#cfgTerminal');
|
|
511
|
-
termSel.innerHTML = (state.terminals || []).map((t) =>
|
|
512
|
-
`<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} · ${escapeHtml(t.processName)}</option>`
|
|
513
|
-
).join('');
|
|
514
|
-
|
|
515
|
-
$('#cfgFinderPrompt').value = state.config.finderPrompt || '';
|
|
516
|
-
|
|
517
|
-
const tb = $('#reposTable tbody');
|
|
518
|
-
tb.innerHTML = '';
|
|
519
|
-
(state.config.repos || []).forEach((r, idx) => {
|
|
520
|
-
const tr = document.createElement('tr');
|
|
521
|
-
tr.innerHTML = `
|
|
522
|
-
<td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}" /></td>
|
|
523
|
-
<td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}" /></td>
|
|
524
|
-
<td class="num"><input type="checkbox" data-field="defaultSelected" data-idx="${idx}" ${r.defaultSelected ? 'checked' : ''} /></td>
|
|
525
|
-
<td><div class="row-actions"><button class="action tiny danger" data-remove-repo="${idx}">Remove</button></div></td>
|
|
526
|
-
`;
|
|
527
|
-
tb.appendChild(tr);
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function readConfigFromForm() {
|
|
532
|
-
const repos = $$('#reposTable tbody tr').map((tr) => {
|
|
533
|
-
const inputs = tr.querySelectorAll('input');
|
|
534
|
-
return {
|
|
535
|
-
name: inputs[0].value.trim(),
|
|
536
|
-
url: inputs[1].value.trim(),
|
|
537
|
-
defaultSelected: inputs[2].checked,
|
|
538
|
-
};
|
|
539
|
-
}).filter((r) => r.name && r.url);
|
|
540
|
-
|
|
541
|
-
return {
|
|
542
|
-
port: Number($('#cfgPort').value) || 7777,
|
|
543
|
-
workDir: $('#cfgWorkDir').value.trim(),
|
|
544
|
-
snapshotIntervalMs: Math.max(5000, Number($('#cfgInterval').value) || 60000),
|
|
545
|
-
snapshotHistoryKeep: Math.max(1, Number($('#cfgKeep').value) || 30),
|
|
546
|
-
claudeCommand: ($('#cfgClaudeCommand').value || 'claude').trim(),
|
|
547
|
-
terminal: $('#cfgTerminal').value || 'wt',
|
|
548
|
-
commandShell: $('#cfgCommandShell').value || 'pwsh',
|
|
549
|
-
autoFocusOnLaunch: $('#cfgAutoFocus').checked,
|
|
550
|
-
focusMovesToCenter: $('#cfgFocusCenter').checked,
|
|
551
|
-
browserMode: $('#cfgBrowserMode').value || 'app',
|
|
552
|
-
finderPrompt: $('#cfgFinderPrompt').value,
|
|
553
|
-
repos,
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/* ─────────────────────────────────────────────────────────────
|
|
558
|
-
Header + footer status
|
|
559
|
-
───────────────────────────────────────────────────────────── */
|
|
560
|
-
|
|
561
|
-
function renderHeaderStatus() {
|
|
562
|
-
if (!state.config) return;
|
|
563
|
-
$('#hdPort').textContent = String(state.config.port);
|
|
564
|
-
$('#hdTerminal').textContent =
|
|
565
|
-
`${state.config.terminal} · ${state.config.claudeCommand}` +
|
|
566
|
-
(state.config.terminal === 'wt' ? ` (${state.config.commandShell})` : '');
|
|
567
|
-
$('#footWorkDir').textContent = state.config.workDir;
|
|
568
|
-
$('#footData').textContent = '~/.ccsm';
|
|
569
|
-
}
|
|
570
|
-
function tickClock() {
|
|
571
|
-
const t = new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
572
|
-
const el = $('#hdTime');
|
|
573
|
-
if (el) el.textContent = t;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/* ── Server health poll ── */
|
|
577
|
-
async function pollHealth() {
|
|
578
|
-
const ctrl = new AbortController();
|
|
579
|
-
const timeout = setTimeout(() => ctrl.abort(), 3000);
|
|
580
|
-
try {
|
|
581
|
-
const r = await fetch('/api/health', { signal: ctrl.signal });
|
|
582
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
583
|
-
const j = await r.json();
|
|
584
|
-
state.serverHealth = { state: 'online', version: j.version, pid: j.pid };
|
|
585
|
-
} catch (e) {
|
|
586
|
-
state.serverHealth = { state: 'offline', error: String(e.message || e) };
|
|
587
|
-
} finally {
|
|
588
|
-
clearTimeout(timeout);
|
|
589
|
-
}
|
|
590
|
-
renderServerStatus();
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function renderServerStatus() {
|
|
594
|
-
const el = $('#serverStatus');
|
|
595
|
-
if (!el) return;
|
|
596
|
-
const h = state.serverHealth || { state: 'connecting' };
|
|
597
|
-
el.dataset.state = h.state;
|
|
598
|
-
const label = $('#serverStatusLabel');
|
|
599
|
-
if (!label) return;
|
|
600
|
-
if (h.state === 'online') {
|
|
601
|
-
label.textContent = h.version ? `online · v${h.version}` : 'online';
|
|
602
|
-
el.title = `backend ok · pid ${h.pid} · v${h.version}`;
|
|
603
|
-
} else if (h.state === 'offline') {
|
|
604
|
-
label.textContent = 'offline';
|
|
605
|
-
el.title = `backend unreachable — ${h.error || ''}`;
|
|
606
|
-
} else {
|
|
607
|
-
label.textContent = 'connecting…';
|
|
608
|
-
el.title = 'checking backend status';
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/* ─────────────────────────────────────────────────────────────
|
|
613
|
-
Loaders
|
|
614
|
-
───────────────────────────────────────────────────────────── */
|
|
615
|
-
|
|
616
|
-
async function loadConfig() {
|
|
617
|
-
const [cfg, terminals] = await Promise.all([
|
|
618
|
-
api('GET', '/api/config'),
|
|
619
|
-
api('GET', '/api/terminals'),
|
|
620
|
-
]);
|
|
621
|
-
state.config = cfg;
|
|
622
|
-
state.terminals = terminals.terminals;
|
|
623
|
-
renderConfig();
|
|
624
|
-
renderRepoPicker();
|
|
625
|
-
renderHeaderStatus();
|
|
626
|
-
}
|
|
627
|
-
async function loadSessions() {
|
|
628
|
-
const r = await api('GET', '/api/sessions');
|
|
629
|
-
state.sessions = r.sessions;
|
|
630
|
-
renderSessions();
|
|
631
|
-
}
|
|
632
|
-
async function loadRecent() {
|
|
633
|
-
const r = await api('GET', `/api/sessions/recent?limit=${state.recentLimit}&offset=${state.recentOffset}`);
|
|
634
|
-
state.recent = r.recent;
|
|
635
|
-
state.recentTotal = r.total || 0;
|
|
636
|
-
state.recentLimit = r.limit || state.recentLimit;
|
|
637
|
-
state.recentOffset = r.offset || 0;
|
|
638
|
-
renderRecent();
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
async function loadFavorites() {
|
|
642
|
-
try {
|
|
643
|
-
const r = await api('GET', '/api/favorites');
|
|
644
|
-
const map = {};
|
|
645
|
-
for (const f of r.favorites || []) map[f.sessionId] = f;
|
|
646
|
-
state.favorites = map;
|
|
647
|
-
renderFavorites();
|
|
648
|
-
} catch (e) { /* ignore */ }
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async function loadLabels() {
|
|
652
|
-
try {
|
|
653
|
-
const r = await api('GET', '/api/labels');
|
|
654
|
-
state.labels = r.labels || {};
|
|
655
|
-
} catch (e) { /* ignore */ }
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
async function renameSession(sessionId, currentLabel) {
|
|
659
|
-
const next = await ccsmPrompt('Rename session', currentLabel || '', {
|
|
660
|
-
title: 'Rename session',
|
|
661
|
-
placeholder: 'leave empty to clear the label',
|
|
662
|
-
okLabel: 'Save',
|
|
663
|
-
});
|
|
664
|
-
if (next === null) return; // user cancelled
|
|
665
|
-
const trimmed = next.trim();
|
|
666
|
-
// optimistic
|
|
667
|
-
const prev = state.labels[sessionId];
|
|
668
|
-
if (trimmed) state.labels[sessionId] = trimmed;
|
|
669
|
-
else delete state.labels[sessionId];
|
|
670
|
-
renderSessions();
|
|
671
|
-
renderRecent();
|
|
672
|
-
renderFavorites();
|
|
673
|
-
try {
|
|
674
|
-
if (trimmed) {
|
|
675
|
-
await api('PUT', `/api/labels/${sessionId}`, { label: trimmed });
|
|
676
|
-
toast(`renamed · ${sessionId.slice(0, 8)}`);
|
|
677
|
-
} else {
|
|
678
|
-
await api('DELETE', `/api/labels/${sessionId}`);
|
|
679
|
-
toast(`cleared label · ${sessionId.slice(0, 8)}`);
|
|
680
|
-
}
|
|
681
|
-
} catch (e) {
|
|
682
|
-
// rollback
|
|
683
|
-
if (prev !== undefined) state.labels[sessionId] = prev;
|
|
684
|
-
else delete state.labels[sessionId];
|
|
685
|
-
renderSessions();
|
|
686
|
-
renderRecent();
|
|
687
|
-
renderFavorites();
|
|
688
|
-
toast('rename failed: ' + e.message, 'error');
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async function toggleFavorite(sessionId, sourceRow) {
|
|
693
|
-
const wasFav = !!state.favorites[sessionId];
|
|
694
|
-
if (wasFav) {
|
|
695
|
-
// optimistic remove
|
|
696
|
-
delete state.favorites[sessionId];
|
|
697
|
-
renderFavorites();
|
|
698
|
-
renderSessions();
|
|
699
|
-
renderRecent();
|
|
700
|
-
try { await api('DELETE', `/api/favorites/${sessionId}`); }
|
|
701
|
-
catch (e) { toast('unfavorite failed: ' + e.message, 'error'); }
|
|
702
|
-
} else {
|
|
703
|
-
// optimistic add — snapshot row's data so the favorite is meaningful
|
|
704
|
-
// even when the session later moves out of live/recent
|
|
705
|
-
const cwd = sourceRow?.dataset?.cwd || '';
|
|
706
|
-
const title = sourceRow?.dataset?.title || '';
|
|
707
|
-
const gitBranch = sourceRow?.dataset?.gitBranch || '';
|
|
708
|
-
state.favorites[sessionId] = { sessionId, cwd, title, gitBranch, addedAt: Date.now() };
|
|
709
|
-
renderFavorites();
|
|
710
|
-
renderSessions();
|
|
711
|
-
renderRecent();
|
|
712
|
-
try { await api('POST', `/api/favorites/${sessionId}`, { cwd, title, gitBranch }); }
|
|
713
|
-
catch (e) { toast('favorite failed: ' + e.message, 'error'); }
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
async function loadSnapshot() {
|
|
717
|
-
const r = await api('GET', '/api/snapshot');
|
|
718
|
-
state.snapshot = r.snapshot;
|
|
719
|
-
const h = await api('GET', '/api/snapshot/history');
|
|
720
|
-
state.history = h.history;
|
|
721
|
-
renderSnapshot();
|
|
722
|
-
}
|
|
723
|
-
async function loadWorkspaces() {
|
|
724
|
-
const r = await api('GET', '/api/workspaces');
|
|
725
|
-
state.workspaces = r.workspaces;
|
|
726
|
-
renderWorkspaces();
|
|
727
|
-
}
|
|
728
|
-
async function refreshAll() {
|
|
729
|
-
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites(), loadLabels()]);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/* ─────────────────────────────────────────────────────────────
|
|
733
|
-
Clone progress stream (NDJSON)
|
|
734
|
-
───────────────────────────────────────────────────────────── */
|
|
735
|
-
|
|
736
|
-
function resetProgress(repoNames, rootId = 'newSessionProgress') {
|
|
737
|
-
const root = document.getElementById(rootId);
|
|
738
|
-
if (!root) return;
|
|
739
|
-
root.innerHTML = '';
|
|
740
|
-
for (const r of repoNames) {
|
|
741
|
-
const el = document.createElement('div');
|
|
742
|
-
el.className = 'progress-item';
|
|
743
|
-
el.dataset.repo = r;
|
|
744
|
-
el.innerHTML = `
|
|
745
|
-
<div class="head">
|
|
746
|
-
<span class="name">${escapeHtml(r)}</span>
|
|
747
|
-
<span class="phase">queued</span>
|
|
748
|
-
<span class="pct"></span>
|
|
749
|
-
</div>
|
|
750
|
-
<div class="progress-bar"><div class="fill"></div></div>
|
|
751
|
-
<div class="detail"></div>
|
|
752
|
-
`;
|
|
753
|
-
root.appendChild(el);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
function progressItem(repo, rootId = 'newSessionProgress') {
|
|
757
|
-
return document.querySelector(`#${rootId} .progress-item[data-repo="${CSS.escape(repo)}"]`);
|
|
758
|
-
}
|
|
759
|
-
function setProgress(repo, { phase, percent, detail, state, indeterminate, rootId } = {}) {
|
|
760
|
-
const el = progressItem(repo, rootId);
|
|
761
|
-
if (!el) return;
|
|
762
|
-
if (state) {
|
|
763
|
-
el.classList.remove('ok', 'error');
|
|
764
|
-
if (state === 'ok' || state === 'error') el.classList.add(state);
|
|
765
|
-
}
|
|
766
|
-
if (phase != null) el.querySelector('.phase').textContent = phase;
|
|
767
|
-
if (percent != null) {
|
|
768
|
-
el.querySelector('.pct').textContent = `${percent}%`;
|
|
769
|
-
el.querySelector('.fill').style.width = `${percent}%`;
|
|
770
|
-
el.querySelector('.fill').classList.remove('indeterminate');
|
|
771
|
-
}
|
|
772
|
-
if (indeterminate) {
|
|
773
|
-
el.querySelector('.fill').classList.add('indeterminate');
|
|
774
|
-
el.querySelector('.pct').textContent = '';
|
|
775
|
-
}
|
|
776
|
-
if (detail != null) el.querySelector('.detail').textContent = detail;
|
|
777
|
-
}
|
|
778
|
-
async function streamNewSession(body, { progressRootId = 'newSessionProgress', resultElId = 'newSessionResult' } = {}) {
|
|
779
|
-
const res = await fetch('/api/sessions/new', {
|
|
780
|
-
method: 'POST',
|
|
781
|
-
headers: { 'Content-Type': 'application/json' },
|
|
782
|
-
body: JSON.stringify(body),
|
|
783
|
-
});
|
|
784
|
-
if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
|
|
785
|
-
const j = await res.json();
|
|
786
|
-
throw new Error(j.error || `HTTP ${res.status}`);
|
|
787
|
-
}
|
|
788
|
-
const reader = res.body.getReader();
|
|
789
|
-
const decoder = new TextDecoder();
|
|
790
|
-
let buf = '';
|
|
791
|
-
let final = null;
|
|
792
|
-
while (true) {
|
|
793
|
-
const { done, value } = await reader.read();
|
|
794
|
-
if (done) break;
|
|
795
|
-
buf += decoder.decode(value, { stream: true });
|
|
796
|
-
const lines = buf.split('\n');
|
|
797
|
-
buf = lines.pop();
|
|
798
|
-
for (const line of lines) {
|
|
799
|
-
if (!line.trim()) continue;
|
|
800
|
-
let event;
|
|
801
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
802
|
-
handleNewSessionEvent(event, { progressRootId, resultElId });
|
|
803
|
-
if (event.type === 'done') final = event;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
if (buf.trim()) {
|
|
807
|
-
try {
|
|
808
|
-
const event = JSON.parse(buf);
|
|
809
|
-
handleNewSessionEvent(event, { progressRootId, resultElId });
|
|
810
|
-
if (event.type === 'done') final = event;
|
|
811
|
-
} catch {}
|
|
812
|
-
}
|
|
813
|
-
return final || { success: false, error: 'stream ended unexpectedly' };
|
|
814
|
-
}
|
|
815
|
-
function handleNewSessionEvent(ev, { progressRootId, resultElId } = {}) {
|
|
816
|
-
const resultEl = document.getElementById(resultElId);
|
|
817
|
-
switch (ev.type) {
|
|
818
|
-
case 'workspace':
|
|
819
|
-
if (resultEl) resultEl.textContent =
|
|
820
|
-
`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
|
|
821
|
-
break;
|
|
822
|
-
case 'clone-start':
|
|
823
|
-
setProgress(ev.repo, { phase: 'starting', indeterminate: true, rootId: progressRootId });
|
|
824
|
-
break;
|
|
825
|
-
case 'clone-progress':
|
|
826
|
-
setProgress(ev.repo, {
|
|
827
|
-
phase: ev.phase,
|
|
828
|
-
percent: ev.percent,
|
|
829
|
-
detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
|
|
830
|
-
rootId: progressRootId,
|
|
831
|
-
});
|
|
832
|
-
break;
|
|
833
|
-
case 'clone-end':
|
|
834
|
-
if (ev.ok) {
|
|
835
|
-
setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok', rootId: progressRootId });
|
|
836
|
-
} else {
|
|
837
|
-
setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error', rootId: progressRootId });
|
|
838
|
-
}
|
|
839
|
-
break;
|
|
840
|
-
case 'launched':
|
|
841
|
-
if (resultEl) resultEl.textContent =
|
|
842
|
-
`terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
|
|
843
|
-
break;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/* ── Modal lifecycle ── */
|
|
848
|
-
function openModal() {
|
|
849
|
-
// refresh modal contents from current state
|
|
850
|
-
renderRepoPicker();
|
|
851
|
-
renderWorkspaces();
|
|
852
|
-
renderModalReposEditor();
|
|
853
|
-
document.getElementById('modalProgress').innerHTML = '';
|
|
854
|
-
document.getElementById('modalResult').textContent = '';
|
|
855
|
-
$('#newSessionModal').hidden = false;
|
|
856
|
-
document.body.style.overflow = 'hidden';
|
|
857
|
-
}
|
|
858
|
-
function closeModal() {
|
|
859
|
-
$('#newSessionModal').hidden = true;
|
|
860
|
-
document.body.style.overflow = '';
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/* ─────────────────────────────────────────────────────────────
|
|
864
|
-
Custom confirm / prompt — replaces native alert/confirm/prompt
|
|
865
|
-
───────────────────────────────────────────────────────────── */
|
|
866
|
-
|
|
867
|
-
/* Promise-based confirm. Resolves true on OK, false on cancel / ESC /
|
|
868
|
-
backdrop click. Optional `danger` flag styles the OK button red. */
|
|
869
|
-
function ccsmConfirm(message, { title = 'Confirm', okLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = {}) {
|
|
870
|
-
return new Promise((resolve) => {
|
|
871
|
-
const backdrop = document.createElement('div');
|
|
872
|
-
backdrop.className = 'modal-backdrop';
|
|
873
|
-
backdrop.setAttribute('role', 'dialog');
|
|
874
|
-
backdrop.setAttribute('aria-modal', 'true');
|
|
875
|
-
backdrop.innerHTML = `
|
|
876
|
-
<div class="modal modal-dialog">
|
|
877
|
-
<header class="modal-head"><h2>${escapeHtml(title)}</h2></header>
|
|
878
|
-
<div class="modal-body"><p class="dialog-msg">${escapeHtml(message)}</p></div>
|
|
879
|
-
<footer class="modal-foot">
|
|
880
|
-
<button class="action" data-action="cancel">${escapeHtml(cancelLabel)}</button>
|
|
881
|
-
<button class="action ${danger ? 'danger' : 'primary'}" data-action="ok">${escapeHtml(okLabel)}</button>
|
|
882
|
-
</footer>
|
|
883
|
-
</div>
|
|
884
|
-
`;
|
|
885
|
-
document.body.appendChild(backdrop);
|
|
886
|
-
const prevOverflow = document.body.style.overflow;
|
|
887
|
-
document.body.style.overflow = 'hidden';
|
|
888
|
-
|
|
889
|
-
const cleanup = (result) => {
|
|
890
|
-
document.removeEventListener('keydown', onKey);
|
|
891
|
-
backdrop.remove();
|
|
892
|
-
document.body.style.overflow = prevOverflow;
|
|
893
|
-
resolve(result);
|
|
894
|
-
};
|
|
895
|
-
const onKey = (ev) => {
|
|
896
|
-
if (ev.key === 'Escape') { ev.preventDefault(); cleanup(false); }
|
|
897
|
-
else if (ev.key === 'Enter') { ev.preventDefault(); cleanup(true); }
|
|
898
|
-
};
|
|
899
|
-
backdrop.addEventListener('click', (ev) => {
|
|
900
|
-
if (ev.target === backdrop) return cleanup(false);
|
|
901
|
-
const btn = ev.target.closest('button[data-action]');
|
|
902
|
-
if (btn) cleanup(btn.dataset.action === 'ok');
|
|
903
|
-
});
|
|
904
|
-
document.addEventListener('keydown', onKey);
|
|
905
|
-
setTimeout(() => backdrop.querySelector('[data-action="ok"]')?.focus(), 50);
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/* Promise-based prompt. Resolves with entered string (possibly "") on OK,
|
|
910
|
-
null on cancel / ESC / backdrop click. */
|
|
911
|
-
function ccsmPrompt(message, defaultValue = '', { title, okLabel = 'Save', cancelLabel = 'Cancel', placeholder = '' } = {}) {
|
|
912
|
-
return new Promise((resolve) => {
|
|
913
|
-
const backdrop = document.createElement('div');
|
|
914
|
-
backdrop.className = 'modal-backdrop';
|
|
915
|
-
backdrop.setAttribute('role', 'dialog');
|
|
916
|
-
backdrop.setAttribute('aria-modal', 'true');
|
|
917
|
-
backdrop.innerHTML = `
|
|
918
|
-
<div class="modal modal-dialog">
|
|
919
|
-
<header class="modal-head"><h2>${escapeHtml(title || message)}</h2></header>
|
|
920
|
-
<div class="modal-body">
|
|
921
|
-
${title ? `<p class="dialog-msg">${escapeHtml(message)}</p>` : ''}
|
|
922
|
-
<input type="text" class="input" placeholder="${escapeHtml(placeholder)}" />
|
|
923
|
-
</div>
|
|
924
|
-
<footer class="modal-foot">
|
|
925
|
-
<button class="action" data-action="cancel">${escapeHtml(cancelLabel)}</button>
|
|
926
|
-
<button class="action primary" data-action="ok">${escapeHtml(okLabel)}</button>
|
|
927
|
-
</footer>
|
|
928
|
-
</div>
|
|
929
|
-
`;
|
|
930
|
-
document.body.appendChild(backdrop);
|
|
931
|
-
const prevOverflow = document.body.style.overflow;
|
|
932
|
-
document.body.style.overflow = 'hidden';
|
|
933
|
-
|
|
934
|
-
const input = backdrop.querySelector('input[type="text"]');
|
|
935
|
-
input.value = defaultValue;
|
|
936
|
-
|
|
937
|
-
const cleanup = (result) => {
|
|
938
|
-
document.removeEventListener('keydown', onKey);
|
|
939
|
-
backdrop.remove();
|
|
940
|
-
document.body.style.overflow = prevOverflow;
|
|
941
|
-
resolve(result);
|
|
942
|
-
};
|
|
943
|
-
const onKey = (ev) => {
|
|
944
|
-
if (ev.key === 'Escape') { ev.preventDefault(); cleanup(null); }
|
|
945
|
-
else if (ev.key === 'Enter') { ev.preventDefault(); cleanup(input.value); }
|
|
946
|
-
};
|
|
947
|
-
backdrop.addEventListener('click', (ev) => {
|
|
948
|
-
if (ev.target === backdrop) return cleanup(null);
|
|
949
|
-
const btn = ev.target.closest('button[data-action]');
|
|
950
|
-
if (btn) cleanup(btn.dataset.action === 'ok' ? input.value : null);
|
|
951
|
-
});
|
|
952
|
-
document.addEventListener('keydown', onKey);
|
|
953
|
-
setTimeout(() => { input.focus(); input.select(); }, 50);
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/* ─────────────────────────────────────────────────────────────
|
|
958
|
-
Wiring
|
|
959
|
-
───────────────────────────────────────────────────────────── */
|
|
960
|
-
|
|
961
|
-
function wireUp() {
|
|
962
|
-
/* sidebar */
|
|
963
|
-
$$('.nav-item').forEach((b) => {
|
|
964
|
-
b.addEventListener('click', () => selectTab(b.dataset.tab));
|
|
965
|
-
});
|
|
966
|
-
$('#collapseBtn').addEventListener('click', toggleSidebar);
|
|
967
|
-
|
|
968
|
-
/* card fold toggles — click anywhere on the card head folds.
|
|
969
|
-
The chevron just visually indicates state; it's not interactive. */
|
|
970
|
-
$$('.card[data-fold-key] .card-head').forEach((head) => {
|
|
971
|
-
head.addEventListener('click', (ev) => {
|
|
972
|
-
const card = head.closest('.card');
|
|
973
|
-
const key = card?.dataset.foldKey;
|
|
974
|
-
if (key) toggleCardFold(key);
|
|
975
|
-
});
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
/* hash routing */
|
|
979
|
-
const hash = location.hash.slice(1);
|
|
980
|
-
if (TAB_HEADINGS[hash]) state.activeTab = hash;
|
|
981
|
-
|
|
982
|
-
$('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
|
|
983
|
-
|
|
984
|
-
/* delegated star + rename across all tables */
|
|
985
|
-
for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
|
|
986
|
-
$(tableSel).addEventListener('click', (ev) => {
|
|
987
|
-
const starBtn = ev.target.closest('button[data-star]');
|
|
988
|
-
if (starBtn) {
|
|
989
|
-
ev.stopPropagation();
|
|
990
|
-
const sessionId = starBtn.dataset.star;
|
|
991
|
-
const row = starBtn.closest('tr');
|
|
992
|
-
toggleFavorite(sessionId, row);
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
const renameBtn = ev.target.closest('button[data-rename]');
|
|
996
|
-
if (renameBtn) {
|
|
997
|
-
ev.stopPropagation();
|
|
998
|
-
const sessionId = renameBtn.dataset.rename;
|
|
999
|
-
renameSession(sessionId, state.labels[sessionId] || '');
|
|
1000
|
-
return;
|
|
1001
|
-
}
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/* favorites table delegated actions (focus / resume / continue) */
|
|
1006
|
-
$('#favoritesTable').addEventListener('click', async (ev) => {
|
|
1007
|
-
const focusBtn = ev.target.closest('button[data-focus]');
|
|
1008
|
-
if (focusBtn) {
|
|
1009
|
-
const sessionId = focusBtn.dataset.focus;
|
|
1010
|
-
focusBtn.disabled = true;
|
|
1011
|
-
try {
|
|
1012
|
-
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
1013
|
-
if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
|
|
1014
|
-
else toast(`focus blocked or not running`, 'error');
|
|
1015
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1016
|
-
finally { focusBtn.disabled = false; }
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
const resumeBtn = ev.target.closest('button[data-resume], button[data-continue]');
|
|
1020
|
-
if (!resumeBtn) return;
|
|
1021
|
-
const sessionId = resumeBtn.dataset.resume || resumeBtn.dataset.continue;
|
|
1022
|
-
const cwd = resumeBtn.dataset.cwd;
|
|
1023
|
-
if (!cwd) return toast('no cwd for this favorite', 'error');
|
|
1024
|
-
resumeBtn.disabled = true;
|
|
1025
|
-
try {
|
|
1026
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
1027
|
-
toast(`opening wt · ${sessionId.slice(0, 8)}…`);
|
|
1028
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1029
|
-
finally { resumeBtn.disabled = false; }
|
|
1030
|
-
});
|
|
1031
|
-
|
|
1032
|
-
/* inline finder button on Sessions tab */
|
|
1033
|
-
const inlineFinder = $('#finderInlineBtn');
|
|
1034
|
-
if (inlineFinder) {
|
|
1035
|
-
inlineFinder.onclick = async () => {
|
|
1036
|
-
try {
|
|
1037
|
-
await api('POST', '/api/sessions/finder');
|
|
1038
|
-
toast('finder session launching in a new wt window');
|
|
1039
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/* recent pagination (server-side) */
|
|
1044
|
-
$('#recentPrevBtn').onclick = () => {
|
|
1045
|
-
state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
|
|
1046
|
-
loadRecent().catch(() => {});
|
|
1047
|
-
};
|
|
1048
|
-
$('#recentNextBtn').onclick = () => {
|
|
1049
|
-
state.recentOffset = state.recentOffset + state.recentLimit;
|
|
1050
|
-
loadRecent().catch(() => {});
|
|
1051
|
-
};
|
|
1052
|
-
$('#recentPageSize').onchange = (e) => {
|
|
1053
|
-
state.recentLimit = Math.max(1, Number(e.target.value) || 10);
|
|
1054
|
-
state.recentOffset = 0;
|
|
1055
|
-
loadRecent().catch(() => {});
|
|
1056
|
-
};
|
|
1057
|
-
|
|
1058
|
-
/* sessions pagination (client-side) */
|
|
1059
|
-
$('#sessPrevBtn').onclick = () => {
|
|
1060
|
-
state.sessionsOffset = Math.max(0, state.sessionsOffset - state.sessionsLimit);
|
|
1061
|
-
renderSessions();
|
|
1062
|
-
};
|
|
1063
|
-
$('#sessNextBtn').onclick = () => {
|
|
1064
|
-
state.sessionsOffset = state.sessionsOffset + state.sessionsLimit;
|
|
1065
|
-
renderSessions();
|
|
1066
|
-
};
|
|
1067
|
-
$('#sessPageSize').onchange = (e) => {
|
|
1068
|
-
state.sessionsLimit = Math.max(1, Number(e.target.value) || 10);
|
|
1069
|
-
state.sessionsOffset = 0;
|
|
1070
|
-
renderSessions();
|
|
1071
|
-
};
|
|
1072
|
-
|
|
1073
|
-
/* favorites pagination (client-side) */
|
|
1074
|
-
$('#favPrevBtn').onclick = () => {
|
|
1075
|
-
state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit);
|
|
1076
|
-
renderFavorites();
|
|
1077
|
-
};
|
|
1078
|
-
$('#favNextBtn').onclick = () => {
|
|
1079
|
-
state.favoritesOffset = state.favoritesOffset + state.favoritesLimit;
|
|
1080
|
-
renderFavorites();
|
|
1081
|
-
};
|
|
1082
|
-
$('#favPageSize').onchange = (e) => {
|
|
1083
|
-
state.favoritesLimit = Math.max(1, Number(e.target.value) || 10);
|
|
1084
|
-
state.favoritesOffset = 0;
|
|
1085
|
-
renderFavorites();
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
/* live sessions actions */
|
|
1089
|
-
$('#sessionsTable').addEventListener('click', async (ev) => {
|
|
1090
|
-
if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
|
|
1091
|
-
const focusBtn = ev.target.closest('button[data-focus]');
|
|
1092
|
-
if (focusBtn) {
|
|
1093
|
-
const sessionId = focusBtn.dataset.focus;
|
|
1094
|
-
focusBtn.disabled = true;
|
|
1095
|
-
try {
|
|
1096
|
-
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
1097
|
-
if (r.ok && r.activated) toast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
|
|
1098
|
-
else if (r.ok) toast(`window found, focus blocked (${r.windowProcess})`, 'error');
|
|
1099
|
-
else toast(`no window for pid · ${(r.chain || []).map((c) => c.name).join('→')}`, 'error');
|
|
1100
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1101
|
-
finally { focusBtn.disabled = false; }
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
const resumeBtn = ev.target.closest('button[data-resume]');
|
|
1105
|
-
if (!resumeBtn) return;
|
|
1106
|
-
const sessionId = resumeBtn.dataset.resume;
|
|
1107
|
-
const cwd = resumeBtn.dataset.cwd;
|
|
1108
|
-
resumeBtn.disabled = true;
|
|
1109
|
-
try {
|
|
1110
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
1111
|
-
toast(`opening wt · ${sessionId.slice(0, 8)}…`);
|
|
1112
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1113
|
-
finally { resumeBtn.disabled = false; }
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
/* recent continue */
|
|
1117
|
-
$('#recentTable').addEventListener('click', async (ev) => {
|
|
1118
|
-
if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
|
|
1119
|
-
const btn = ev.target.closest('button[data-continue]');
|
|
1120
|
-
if (!btn) return;
|
|
1121
|
-
const sessionId = btn.dataset.continue;
|
|
1122
|
-
const cwd = btn.dataset.cwd;
|
|
1123
|
-
btn.disabled = true;
|
|
1124
|
-
try {
|
|
1125
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
1126
|
-
toast(`continuing · ${sessionId.slice(0, 8)}…`);
|
|
1127
|
-
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
1128
|
-
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
1129
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1130
|
-
finally { btn.disabled = false; }
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
/* snapshot */
|
|
1134
|
-
$('#snapshotSaveBtn').onclick = async () => {
|
|
1135
|
-
try {
|
|
1136
|
-
const r = await api('POST', '/api/snapshot');
|
|
1137
|
-
state.snapshot = r.snapshot;
|
|
1138
|
-
const h = await api('GET', '/api/snapshot/history');
|
|
1139
|
-
state.history = h.history;
|
|
1140
|
-
renderSnapshot();
|
|
1141
|
-
toast(`saved · ${r.snapshot.sessions.length} session(s)`);
|
|
1142
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1143
|
-
};
|
|
1144
|
-
$('#snapshotRestoreBtn').onclick = async () => {
|
|
1145
|
-
const snap = state.snapshot;
|
|
1146
|
-
if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
|
|
1147
|
-
const ok = await ccsmConfirm(
|
|
1148
|
-
`Restore ${snap.sessions.length} session(s)? Each opens a new wt window.`,
|
|
1149
|
-
{ title: 'Restore latest snapshot', okLabel: `Restore ${snap.sessions.length}` }
|
|
1150
|
-
);
|
|
1151
|
-
if (!ok) return;
|
|
1152
|
-
try {
|
|
1153
|
-
const r = await api('POST', '/api/snapshot/restore');
|
|
1154
|
-
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
1155
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1156
|
-
};
|
|
1157
|
-
$('#historyRestoreBtn').onclick = async () => {
|
|
1158
|
-
const file = $('#historySelect').value;
|
|
1159
|
-
if (!file) return toast('pick a history snapshot first', 'error');
|
|
1160
|
-
const ok = await ccsmConfirm(`Restore from ${file}?`, {
|
|
1161
|
-
title: 'Restore from history',
|
|
1162
|
-
okLabel: 'Restore',
|
|
1163
|
-
});
|
|
1164
|
-
if (!ok) return;
|
|
1165
|
-
try {
|
|
1166
|
-
const r = await api('POST', '/api/snapshot/restore', { file });
|
|
1167
|
-
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
1168
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1169
|
-
};
|
|
1170
|
-
|
|
1171
|
-
/* shared launcher — drives both the inline form (Launch tab) and the FAB modal */
|
|
1172
|
-
async function launchNewSessionFromForm({ chipSel, wsSelId, progressRootId, resultElId, triggerBtn, onSuccess }) {
|
|
1173
|
-
const repos = $$(chipSel).map((i) => i.dataset.repo);
|
|
1174
|
-
if (repos.length === 0) return toast('select at least one repo', 'error');
|
|
1175
|
-
const workspace = document.getElementById(wsSelId)?.value || undefined;
|
|
1176
|
-
const resultEl = document.getElementById(resultElId);
|
|
1177
|
-
if (triggerBtn) triggerBtn.disabled = true;
|
|
1178
|
-
if (resultEl) resultEl.textContent = '';
|
|
1179
|
-
resetProgress(repos, progressRootId);
|
|
1180
|
-
try {
|
|
1181
|
-
const result = await streamNewSession({ repos, workspace }, { progressRootId, resultElId });
|
|
1182
|
-
if (result.success) {
|
|
1183
|
-
const ws = result.workspace;
|
|
1184
|
-
const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
1185
|
-
if (resultEl) resultEl.textContent =
|
|
1186
|
-
`launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
|
|
1187
|
-
toast(`launched · ${ws.name}`);
|
|
1188
|
-
if (onSuccess) onSuccess(result);
|
|
1189
|
-
} else {
|
|
1190
|
-
if (resultEl) resultEl.textContent = `error: ${result.error}`;
|
|
1191
|
-
toast(result.error || 'new session failed', 'error');
|
|
1192
|
-
}
|
|
1193
|
-
await loadWorkspaces();
|
|
1194
|
-
} catch (e) {
|
|
1195
|
-
if (resultEl) resultEl.textContent = `error: ${e.message}`;
|
|
1196
|
-
toast(e.message, 'error');
|
|
1197
|
-
} finally {
|
|
1198
|
-
if (triggerBtn) triggerBtn.disabled = false;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
$('#newSessionBtn').onclick = () => launchNewSessionFromForm({
|
|
1203
|
-
chipSel: '#repoPicker input:checked',
|
|
1204
|
-
wsSelId: 'workspaceSelect',
|
|
1205
|
-
progressRootId: 'newSessionProgress',
|
|
1206
|
-
resultElId: 'newSessionResult',
|
|
1207
|
-
triggerBtn: $('#newSessionBtn'),
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
/* FAB → modal */
|
|
1211
|
-
$('#newSessionFab').onclick = () => openModal();
|
|
1212
|
-
$('#modalCloseBtn').onclick = () => closeModal();
|
|
1213
|
-
$('#modalCancelBtn').onclick = () => closeModal();
|
|
1214
|
-
$('#newSessionModal').addEventListener('click', (ev) => {
|
|
1215
|
-
if (ev.target === $('#newSessionModal')) closeModal();
|
|
1216
|
-
});
|
|
1217
|
-
document.addEventListener('keydown', (ev) => {
|
|
1218
|
-
if (ev.key === 'Escape' && !$('#newSessionModal').hidden) closeModal();
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
$('#modalLaunchBtn').onclick = () => launchNewSessionFromForm({
|
|
1222
|
-
chipSel: '#modalRepoPicker input:checked',
|
|
1223
|
-
wsSelId: 'modalWorkspaceSelect',
|
|
1224
|
-
progressRootId: 'modalProgress',
|
|
1225
|
-
resultElId: 'modalResult',
|
|
1226
|
-
triggerBtn: $('#modalLaunchBtn'),
|
|
1227
|
-
onSuccess: () => setTimeout(() => closeModal(), 1500),
|
|
1228
|
-
});
|
|
1229
|
-
|
|
1230
|
-
/* modal inline repos editor */
|
|
1231
|
-
$('#modalAddRepoBtn').onclick = () => {
|
|
1232
|
-
state.config.repos = state.config.repos || [];
|
|
1233
|
-
state.config.repos.push({ name: '', url: '', defaultSelected: false });
|
|
1234
|
-
renderModalReposEditor();
|
|
1235
|
-
};
|
|
1236
|
-
$('#modalReposTable').addEventListener('click', (ev) => {
|
|
1237
|
-
const rm = ev.target.closest('button[data-modal-remove]');
|
|
1238
|
-
if (!rm) return;
|
|
1239
|
-
const idx = Number(rm.dataset.modalRemove);
|
|
1240
|
-
state.config.repos.splice(idx, 1);
|
|
1241
|
-
renderModalReposEditor();
|
|
1242
|
-
});
|
|
1243
|
-
$('#modalSaveReposBtn').onclick = async () => {
|
|
1244
|
-
const repos = $$('#modalReposTable tbody tr').map((tr) => {
|
|
1245
|
-
const inputs = tr.querySelectorAll('input');
|
|
1246
|
-
return {
|
|
1247
|
-
name: inputs[0].value.trim(),
|
|
1248
|
-
url: inputs[1].value.trim(),
|
|
1249
|
-
defaultSelected: inputs[2].checked,
|
|
1250
|
-
};
|
|
1251
|
-
}).filter((r) => r.name && r.url);
|
|
1252
|
-
try {
|
|
1253
|
-
const cfg = await api('PUT', '/api/config', { ...state.config, repos });
|
|
1254
|
-
state.config = cfg;
|
|
1255
|
-
renderConfig(); // sync Configure tab
|
|
1256
|
-
renderRepoPicker(); // sync both chip pickers
|
|
1257
|
-
renderModalReposEditor(); // refresh modal editor
|
|
1258
|
-
$('#modalReposSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
|
|
1259
|
-
toast('repos saved');
|
|
1260
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1261
|
-
};
|
|
1262
|
-
|
|
1263
|
-
/* config save */
|
|
1264
|
-
const saveConfig = async () => {
|
|
1265
|
-
const next = readConfigFromForm();
|
|
1266
|
-
try {
|
|
1267
|
-
const cfg = await api('PUT', '/api/config', next);
|
|
1268
|
-
state.config = cfg;
|
|
1269
|
-
renderConfig();
|
|
1270
|
-
renderRepoPicker();
|
|
1271
|
-
renderHeaderStatus();
|
|
1272
|
-
$('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
|
|
1273
|
-
setConfigDirty(false);
|
|
1274
|
-
toast('config saved');
|
|
1275
|
-
await loadWorkspaces();
|
|
1276
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
1277
|
-
};
|
|
1278
|
-
$('#saveConfigBtn').onclick = saveConfig;
|
|
1279
|
-
$('#dirtyBannerSaveBtn').onclick = saveConfig;
|
|
1280
|
-
$('#dirtyBannerDiscardBtn').onclick = async () => {
|
|
1281
|
-
const ok = await ccsmConfirm('Discard your unsaved changes?', {
|
|
1282
|
-
title: 'Discard changes',
|
|
1283
|
-
okLabel: 'Discard',
|
|
1284
|
-
danger: true,
|
|
1285
|
-
});
|
|
1286
|
-
if (!ok) return;
|
|
1287
|
-
// re-fetch config from server and re-render
|
|
1288
|
-
state.config = await api('GET', '/api/config');
|
|
1289
|
-
renderConfig();
|
|
1290
|
-
renderRepoPicker();
|
|
1291
|
-
renderHeaderStatus();
|
|
1292
|
-
setConfigDirty(false);
|
|
1293
|
-
toast('changes discarded');
|
|
1294
|
-
};
|
|
1295
|
-
$('#addRepoBtn').onclick = () => {
|
|
1296
|
-
state.config.repos.push({ name: '', url: '', defaultSelected: false });
|
|
1297
|
-
renderConfig();
|
|
1298
|
-
setConfigDirty(true);
|
|
1299
|
-
};
|
|
1300
|
-
$('#reposTable').addEventListener('click', (ev) => {
|
|
1301
|
-
const rm = ev.target.closest('button[data-remove-repo]');
|
|
1302
|
-
if (!rm) return;
|
|
1303
|
-
const idx = Number(rm.dataset.removeRepo);
|
|
1304
|
-
state.config.repos.splice(idx, 1);
|
|
1305
|
-
renderConfig();
|
|
1306
|
-
setConfigDirty(true);
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
/* Mark dirty on any user-initiated change in the Configure tab */
|
|
1310
|
-
const configPanel = document.querySelector('.tab-panel[data-panel="configure"]');
|
|
1311
|
-
if (configPanel) {
|
|
1312
|
-
const onChange = () => setConfigDirty(true);
|
|
1313
|
-
configPanel.addEventListener('input', onChange);
|
|
1314
|
-
configPanel.addEventListener('change', onChange);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
function startAutoRefresh() {
|
|
1319
|
-
if (state.autoTimer) clearInterval(state.autoTimer);
|
|
1320
|
-
state.autoTimer = setInterval(() => {
|
|
1321
|
-
loadSessions().catch(() => {});
|
|
1322
|
-
loadRecent().catch(() => {});
|
|
1323
|
-
loadSnapshot().catch(() => {});
|
|
1324
|
-
pollHealth();
|
|
1325
|
-
}, 5000);
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Re-render favorites when sessions update so live status of favorited rows refreshes.
|
|
1329
|
-
function reRenderFavoritesIfNeeded() {
|
|
1330
|
-
if (Object.keys(state.favorites).length === 0) return;
|
|
1331
|
-
renderFavorites();
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
/* ─────────────────────────────────────────────────────────────
|
|
1335
|
-
Boot
|
|
1336
|
-
───────────────────────────────────────────────────────────── */
|
|
1337
|
-
|
|
1338
|
-
(async () => {
|
|
1339
|
-
restoreSidebar();
|
|
1340
|
-
restoreCardFolds();
|
|
1341
|
-
wireUp();
|
|
1342
|
-
try {
|
|
1343
|
-
await loadConfig();
|
|
1344
|
-
await refreshAll();
|
|
1345
|
-
selectTab(state.activeTab);
|
|
1346
|
-
startAutoRefresh();
|
|
1347
|
-
pollHealth();
|
|
1348
|
-
tickClock();
|
|
1349
|
-
state.clockTimer = setInterval(tickClock, 1000);
|
|
1350
|
-
} catch (e) {
|
|
1351
|
-
toast('initial load failed · ' + e.message, 'error');
|
|
1352
|
-
}
|
|
1353
|
-
})();
|