@bakapiano/ccsm 0.5.0 → 0.6.0
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/lib/config.js +1 -0
- package/lib/focus.js +90 -14
- package/lib/labels.js +49 -0
- package/lib/workspace.js +8 -4
- package/package.json +1 -1
- package/public/app.js +556 -97
- package/public/favicon.svg +18 -0
- package/public/index.html +135 -23
- package/public/styles.css +464 -29
- package/server.js +28 -1
package/public/app.js
CHANGED
|
@@ -14,8 +14,16 @@ const state = {
|
|
|
14
14
|
recent: [],
|
|
15
15
|
recentTotal: 0,
|
|
16
16
|
recentOffset: 0,
|
|
17
|
-
recentLimit:
|
|
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' },
|
|
18
25
|
favorites: {}, // { sessionId: { sessionId, cwd, title, gitBranch, addedAt, label } }
|
|
26
|
+
labels: {}, // { sessionId: customLabel } — user-defined title overrides
|
|
19
27
|
workspaces: [],
|
|
20
28
|
snapshot: null,
|
|
21
29
|
history: [],
|
|
@@ -77,6 +85,25 @@ function escapeHtml(s) {
|
|
|
77
85
|
|
|
78
86
|
/* Mark a table as already-rendered so animations don't replay on
|
|
79
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
|
+
|
|
80
107
|
function markRendered(tableId) {
|
|
81
108
|
const tb = document.querySelector(`#${tableId} tbody`);
|
|
82
109
|
if (!tb) return;
|
|
@@ -105,6 +132,21 @@ function starButtonHtml(sessionId, isFav) {
|
|
|
105
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>`;
|
|
106
133
|
}
|
|
107
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
|
+
|
|
108
150
|
/* ─────────────────────────────────────────────────────────────
|
|
109
151
|
Sidebar — tabs + collapse
|
|
110
152
|
───────────────────────────────────────────────────────────── */
|
|
@@ -136,6 +178,38 @@ function restoreSidebar() {
|
|
|
136
178
|
if (v === 'true') $('#sidebar').setAttribute('data-collapsed', 'true');
|
|
137
179
|
}
|
|
138
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
|
+
|
|
139
213
|
/* ─────────────────────────────────────────────────────────────
|
|
140
214
|
Render: sessions (live)
|
|
141
215
|
───────────────────────────────────────────────────────────── */
|
|
@@ -143,15 +217,24 @@ function restoreSidebar() {
|
|
|
143
217
|
function renderSessions() {
|
|
144
218
|
const tb = $('#sessionsTable tbody');
|
|
145
219
|
tb.innerHTML = '';
|
|
146
|
-
|
|
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) {
|
|
147
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;
|
|
148
230
|
const tr = document.createElement('tr');
|
|
149
231
|
tr.innerHTML = `
|
|
150
232
|
<td><span class="status-mark ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}"></span></td>
|
|
151
233
|
<td>
|
|
152
234
|
<div class="title-cell">
|
|
153
235
|
<div class="title-row">
|
|
154
|
-
<span class="primary" title="${escapeHtml(
|
|
236
|
+
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
237
|
+
${renameButtonHtml(s.sessionId, hasLabel)}
|
|
155
238
|
${starButtonHtml(s.sessionId, isFav)}
|
|
156
239
|
</div>
|
|
157
240
|
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}${s.version ? ' · ' + escapeHtml(s.version) : ''}</div>
|
|
@@ -178,6 +261,11 @@ function renderSessions() {
|
|
|
178
261
|
? `${state.sessions.length} live · refreshed ${ts}`
|
|
179
262
|
: 'no live sessions';
|
|
180
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
|
+
});
|
|
181
269
|
markRendered('sessionsTable');
|
|
182
270
|
}
|
|
183
271
|
|
|
@@ -191,12 +279,16 @@ function renderRecent() {
|
|
|
191
279
|
const recent = state.recent || [];
|
|
192
280
|
for (const s of recent) {
|
|
193
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;
|
|
194
285
|
const tr = document.createElement('tr');
|
|
195
286
|
tr.innerHTML = `
|
|
196
287
|
<td>
|
|
197
288
|
<div class="title-cell">
|
|
198
289
|
<div class="title-row">
|
|
199
|
-
<span class="primary" title="${escapeHtml(
|
|
290
|
+
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
291
|
+
${renameButtonHtml(s.sessionId, hasLabel)}
|
|
200
292
|
${starButtonHtml(s.sessionId, isFav)}
|
|
201
293
|
</div>
|
|
202
294
|
<div class="secondary" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0, 8))}</div>
|
|
@@ -218,25 +310,14 @@ function renderRecent() {
|
|
|
218
310
|
tb.appendChild(tr);
|
|
219
311
|
}
|
|
220
312
|
$('#recentEmpty').hidden = recent.length > 0;
|
|
221
|
-
|
|
222
|
-
|
|
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`
|
|
313
|
+
$('#recentMeta').textContent = state.recentTotal
|
|
314
|
+
? `${state.recentTotal} total · sorted by jsonl mtime, excluding live`
|
|
229
315
|
: 'no recent sessions';
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
$('#recentPrevBtn').disabled = offset === 0;
|
|
236
|
-
$('#recentNextBtn').disabled = offset + limit >= total;
|
|
237
|
-
} else {
|
|
238
|
-
$('#recentPagination').hidden = true;
|
|
239
|
-
}
|
|
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
|
+
});
|
|
240
321
|
markRendered('recentTable');
|
|
241
322
|
}
|
|
242
323
|
|
|
@@ -246,7 +327,11 @@ function renderRecent() {
|
|
|
246
327
|
function renderFavorites() {
|
|
247
328
|
const tb = $('#favoritesTable tbody');
|
|
248
329
|
tb.innerHTML = '';
|
|
249
|
-
const
|
|
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);
|
|
250
335
|
for (const f of list) {
|
|
251
336
|
const liveMatch = state.sessions.find((s) => s.sessionId === f.sessionId);
|
|
252
337
|
const title = liveMatch?.title || f.title;
|
|
@@ -256,12 +341,16 @@ function renderFavorites() {
|
|
|
256
341
|
? `<button class="action small primary" data-focus="${escapeHtml(f.sessionId)}" title="raise the wt window">Focus</button>
|
|
257
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>`
|
|
258
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;
|
|
259
347
|
const tr = document.createElement('tr');
|
|
260
348
|
tr.innerHTML = `
|
|
261
349
|
<td>
|
|
262
350
|
<div class="title-cell">
|
|
263
351
|
<div class="title-row">
|
|
264
|
-
<span class="primary" title="${escapeHtml(
|
|
352
|
+
<span class="primary" title="${escapeHtml(tooltip)}">${escapeHtml(shown)}</span>
|
|
353
|
+
${renameButtonHtml(f.sessionId, hasLabel)}
|
|
265
354
|
${starButtonHtml(f.sessionId, true)}
|
|
266
355
|
</div>
|
|
267
356
|
<div class="secondary" title="${escapeHtml(f.sessionId)}">
|
|
@@ -278,12 +367,17 @@ function renderFavorites() {
|
|
|
278
367
|
tr.dataset.title = title || '';
|
|
279
368
|
tb.appendChild(tr);
|
|
280
369
|
}
|
|
281
|
-
const
|
|
282
|
-
$('#favoritesEmpty').style.display =
|
|
283
|
-
$('#favoritesTable').style.display =
|
|
284
|
-
$('#favoritesMeta').textContent =
|
|
285
|
-
? `${
|
|
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`
|
|
286
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
|
+
});
|
|
287
381
|
markRendered('favoritesTable');
|
|
288
382
|
}
|
|
289
383
|
|
|
@@ -339,11 +433,14 @@ function renderWorkspaces() {
|
|
|
339
433
|
grid.appendChild(card);
|
|
340
434
|
}
|
|
341
435
|
|
|
342
|
-
const
|
|
343
|
-
sel.innerHTML = '<option value="">auto — find or create unused</option>' +
|
|
436
|
+
const opts = '<option value="">auto — find or create unused</option>' +
|
|
344
437
|
state.workspaces.filter((w) => !w.inUse).map((w) =>
|
|
345
438
|
`<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
|
|
346
439
|
).join('');
|
|
440
|
+
for (const id of ['workspaceSelect', 'modalWorkspaceSelect']) {
|
|
441
|
+
const el = document.getElementById(id);
|
|
442
|
+
if (el) el.innerHTML = opts;
|
|
443
|
+
}
|
|
347
444
|
|
|
348
445
|
if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
|
|
349
446
|
}
|
|
@@ -353,24 +450,45 @@ function renderWorkspaces() {
|
|
|
353
450
|
───────────────────────────────────────────────────────────── */
|
|
354
451
|
|
|
355
452
|
function renderRepoPicker() {
|
|
356
|
-
const root = $('#repoPicker');
|
|
357
453
|
const repos = state.config?.repos || [];
|
|
358
|
-
|
|
359
|
-
root
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
chip.
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
371
471
|
}
|
|
372
472
|
}
|
|
373
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
|
+
|
|
374
492
|
/* ─────────────────────────────────────────────────────────────
|
|
375
493
|
Render: config form
|
|
376
494
|
───────────────────────────────────────────────────────────── */
|
|
@@ -384,6 +502,7 @@ function renderConfig() {
|
|
|
384
502
|
$('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
|
|
385
503
|
$('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
|
|
386
504
|
$('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
|
|
505
|
+
$('#cfgFocusCenter').checked = state.config.focusMovesToCenter === true;
|
|
387
506
|
$('#cfgBrowserMode').value =
|
|
388
507
|
state.config.browserMode ||
|
|
389
508
|
(state.config.autoOpenBrowser === false ? 'none' : 'app');
|
|
@@ -428,6 +547,7 @@ function readConfigFromForm() {
|
|
|
428
547
|
terminal: $('#cfgTerminal').value || 'wt',
|
|
429
548
|
commandShell: $('#cfgCommandShell').value || 'pwsh',
|
|
430
549
|
autoFocusOnLaunch: $('#cfgAutoFocus').checked,
|
|
550
|
+
focusMovesToCenter: $('#cfgFocusCenter').checked,
|
|
431
551
|
browserMode: $('#cfgBrowserMode').value || 'app',
|
|
432
552
|
finderPrompt: $('#cfgFinderPrompt').value,
|
|
433
553
|
repos,
|
|
@@ -453,6 +573,42 @@ function tickClock() {
|
|
|
453
573
|
if (el) el.textContent = t;
|
|
454
574
|
}
|
|
455
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
|
+
|
|
456
612
|
/* ─────────────────────────────────────────────────────────────
|
|
457
613
|
Loaders
|
|
458
614
|
───────────────────────────────────────────────────────────── */
|
|
@@ -492,6 +648,47 @@ async function loadFavorites() {
|
|
|
492
648
|
} catch (e) { /* ignore */ }
|
|
493
649
|
}
|
|
494
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
|
+
|
|
495
692
|
async function toggleFavorite(sessionId, sourceRow) {
|
|
496
693
|
const wasFav = !!state.favorites[sessionId];
|
|
497
694
|
if (wasFav) {
|
|
@@ -529,15 +726,16 @@ async function loadWorkspaces() {
|
|
|
529
726
|
renderWorkspaces();
|
|
530
727
|
}
|
|
531
728
|
async function refreshAll() {
|
|
532
|
-
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites()]);
|
|
729
|
+
await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces(), loadFavorites(), loadLabels()]);
|
|
533
730
|
}
|
|
534
731
|
|
|
535
732
|
/* ─────────────────────────────────────────────────────────────
|
|
536
733
|
Clone progress stream (NDJSON)
|
|
537
734
|
───────────────────────────────────────────────────────────── */
|
|
538
735
|
|
|
539
|
-
function resetProgress(repoNames) {
|
|
540
|
-
const root =
|
|
736
|
+
function resetProgress(repoNames, rootId = 'newSessionProgress') {
|
|
737
|
+
const root = document.getElementById(rootId);
|
|
738
|
+
if (!root) return;
|
|
541
739
|
root.innerHTML = '';
|
|
542
740
|
for (const r of repoNames) {
|
|
543
741
|
const el = document.createElement('div');
|
|
@@ -555,11 +753,11 @@ function resetProgress(repoNames) {
|
|
|
555
753
|
root.appendChild(el);
|
|
556
754
|
}
|
|
557
755
|
}
|
|
558
|
-
function progressItem(repo) {
|
|
559
|
-
return document.querySelector(
|
|
756
|
+
function progressItem(repo, rootId = 'newSessionProgress') {
|
|
757
|
+
return document.querySelector(`#${rootId} .progress-item[data-repo="${CSS.escape(repo)}"]`);
|
|
560
758
|
}
|
|
561
|
-
function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
|
|
562
|
-
const el = progressItem(repo);
|
|
759
|
+
function setProgress(repo, { phase, percent, detail, state, indeterminate, rootId } = {}) {
|
|
760
|
+
const el = progressItem(repo, rootId);
|
|
563
761
|
if (!el) return;
|
|
564
762
|
if (state) {
|
|
565
763
|
el.classList.remove('ok', 'error');
|
|
@@ -577,7 +775,7 @@ function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}
|
|
|
577
775
|
}
|
|
578
776
|
if (detail != null) el.querySelector('.detail').textContent = detail;
|
|
579
777
|
}
|
|
580
|
-
async function streamNewSession(body) {
|
|
778
|
+
async function streamNewSession(body, { progressRootId = 'newSessionProgress', resultElId = 'newSessionResult' } = {}) {
|
|
581
779
|
const res = await fetch('/api/sessions/new', {
|
|
582
780
|
method: 'POST',
|
|
583
781
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -601,49 +799,161 @@ async function streamNewSession(body) {
|
|
|
601
799
|
if (!line.trim()) continue;
|
|
602
800
|
let event;
|
|
603
801
|
try { event = JSON.parse(line); } catch { continue; }
|
|
604
|
-
handleNewSessionEvent(event);
|
|
802
|
+
handleNewSessionEvent(event, { progressRootId, resultElId });
|
|
605
803
|
if (event.type === 'done') final = event;
|
|
606
804
|
}
|
|
607
805
|
}
|
|
608
806
|
if (buf.trim()) {
|
|
609
807
|
try {
|
|
610
808
|
const event = JSON.parse(buf);
|
|
611
|
-
handleNewSessionEvent(event);
|
|
809
|
+
handleNewSessionEvent(event, { progressRootId, resultElId });
|
|
612
810
|
if (event.type === 'done') final = event;
|
|
613
811
|
} catch {}
|
|
614
812
|
}
|
|
615
813
|
return final || { success: false, error: 'stream ended unexpectedly' };
|
|
616
814
|
}
|
|
617
|
-
function handleNewSessionEvent(ev) {
|
|
815
|
+
function handleNewSessionEvent(ev, { progressRootId, resultElId } = {}) {
|
|
816
|
+
const resultEl = document.getElementById(resultElId);
|
|
618
817
|
switch (ev.type) {
|
|
619
818
|
case 'workspace':
|
|
620
|
-
|
|
819
|
+
if (resultEl) resultEl.textContent =
|
|
621
820
|
`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`;
|
|
622
821
|
break;
|
|
623
822
|
case 'clone-start':
|
|
624
|
-
setProgress(ev.repo, { phase: 'starting', indeterminate: true });
|
|
823
|
+
setProgress(ev.repo, { phase: 'starting', indeterminate: true, rootId: progressRootId });
|
|
625
824
|
break;
|
|
626
825
|
case 'clone-progress':
|
|
627
826
|
setProgress(ev.repo, {
|
|
628
827
|
phase: ev.phase,
|
|
629
828
|
percent: ev.percent,
|
|
630
829
|
detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
|
|
830
|
+
rootId: progressRootId,
|
|
631
831
|
});
|
|
632
832
|
break;
|
|
633
833
|
case 'clone-end':
|
|
634
834
|
if (ev.ok) {
|
|
635
|
-
setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok' });
|
|
835
|
+
setProgress(ev.repo, { phase: ev.action || 'done', percent: 100, detail: ev.path || '', state: 'ok', rootId: progressRootId });
|
|
636
836
|
} else {
|
|
637
|
-
setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error' });
|
|
837
|
+
setProgress(ev.repo, { phase: 'error', detail: ev.error, state: 'error', rootId: progressRootId });
|
|
638
838
|
}
|
|
639
839
|
break;
|
|
640
840
|
case 'launched':
|
|
641
|
-
|
|
841
|
+
if (resultEl) resultEl.textContent =
|
|
642
842
|
`terminal launching · pid ${ev.launched.pid} · ${ev.launched.terminal}`;
|
|
643
843
|
break;
|
|
644
844
|
}
|
|
645
845
|
}
|
|
646
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
|
+
|
|
647
957
|
/* ─────────────────────────────────────────────────────────────
|
|
648
958
|
Wiring
|
|
649
959
|
───────────────────────────────────────────────────────────── */
|
|
@@ -655,21 +965,40 @@ function wireUp() {
|
|
|
655
965
|
});
|
|
656
966
|
$('#collapseBtn').addEventListener('click', toggleSidebar);
|
|
657
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
|
+
|
|
658
978
|
/* hash routing */
|
|
659
979
|
const hash = location.hash.slice(1);
|
|
660
980
|
if (TAB_HEADINGS[hash]) state.activeTab = hash;
|
|
661
981
|
|
|
662
982
|
$('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
|
|
663
983
|
|
|
664
|
-
/* delegated star
|
|
984
|
+
/* delegated star + rename across all tables */
|
|
665
985
|
for (const tableSel of ['#sessionsTable', '#recentTable', '#favoritesTable']) {
|
|
666
986
|
$(tableSel).addEventListener('click', (ev) => {
|
|
667
987
|
const starBtn = ev.target.closest('button[data-star]');
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
+
}
|
|
673
1002
|
});
|
|
674
1003
|
}
|
|
675
1004
|
|
|
@@ -700,13 +1029,18 @@ function wireUp() {
|
|
|
700
1029
|
finally { resumeBtn.disabled = false; }
|
|
701
1030
|
});
|
|
702
1031
|
|
|
703
|
-
/* inline finder button on Sessions tab
|
|
1032
|
+
/* inline finder button on Sessions tab */
|
|
704
1033
|
const inlineFinder = $('#finderInlineBtn');
|
|
705
1034
|
if (inlineFinder) {
|
|
706
|
-
inlineFinder.onclick = () =>
|
|
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
|
+
};
|
|
707
1041
|
}
|
|
708
1042
|
|
|
709
|
-
/* recent pagination */
|
|
1043
|
+
/* recent pagination (server-side) */
|
|
710
1044
|
$('#recentPrevBtn').onclick = () => {
|
|
711
1045
|
state.recentOffset = Math.max(0, state.recentOffset - state.recentLimit);
|
|
712
1046
|
loadRecent().catch(() => {});
|
|
@@ -716,14 +1050,44 @@ function wireUp() {
|
|
|
716
1050
|
loadRecent().catch(() => {});
|
|
717
1051
|
};
|
|
718
1052
|
$('#recentPageSize').onchange = (e) => {
|
|
719
|
-
state.recentLimit = Math.max(1, Number(e.target.value) ||
|
|
1053
|
+
state.recentLimit = Math.max(1, Number(e.target.value) || 10);
|
|
720
1054
|
state.recentOffset = 0;
|
|
721
1055
|
loadRecent().catch(() => {});
|
|
722
1056
|
};
|
|
723
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
|
+
|
|
724
1088
|
/* live sessions actions */
|
|
725
1089
|
$('#sessionsTable').addEventListener('click', async (ev) => {
|
|
726
|
-
if (ev.target.closest('button[data-star]')) return;
|
|
1090
|
+
if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
|
|
727
1091
|
const focusBtn = ev.target.closest('button[data-focus]');
|
|
728
1092
|
if (focusBtn) {
|
|
729
1093
|
const sessionId = focusBtn.dataset.focus;
|
|
@@ -751,7 +1115,7 @@ function wireUp() {
|
|
|
751
1115
|
|
|
752
1116
|
/* recent continue */
|
|
753
1117
|
$('#recentTable').addEventListener('click', async (ev) => {
|
|
754
|
-
if (ev.target.closest('button[data-star]')) return;
|
|
1118
|
+
if (ev.target.closest('button[data-star]') || ev.target.closest('button[data-rename]')) return;
|
|
755
1119
|
const btn = ev.target.closest('button[data-continue]');
|
|
756
1120
|
if (!btn) return;
|
|
757
1121
|
const sessionId = btn.dataset.continue;
|
|
@@ -766,14 +1130,6 @@ function wireUp() {
|
|
|
766
1130
|
finally { btn.disabled = false; }
|
|
767
1131
|
});
|
|
768
1132
|
|
|
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
1133
|
/* snapshot */
|
|
778
1134
|
$('#snapshotSaveBtn').onclick = async () => {
|
|
779
1135
|
try {
|
|
@@ -788,7 +1144,11 @@ function wireUp() {
|
|
|
788
1144
|
$('#snapshotRestoreBtn').onclick = async () => {
|
|
789
1145
|
const snap = state.snapshot;
|
|
790
1146
|
if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
|
|
791
|
-
|
|
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;
|
|
792
1152
|
try {
|
|
793
1153
|
const r = await api('POST', '/api/snapshot/restore');
|
|
794
1154
|
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
@@ -797,43 +1157,111 @@ function wireUp() {
|
|
|
797
1157
|
$('#historyRestoreBtn').onclick = async () => {
|
|
798
1158
|
const file = $('#historySelect').value;
|
|
799
1159
|
if (!file) return toast('pick a history snapshot first', 'error');
|
|
800
|
-
|
|
1160
|
+
const ok = await ccsmConfirm(`Restore from ${file}?`, {
|
|
1161
|
+
title: 'Restore from history',
|
|
1162
|
+
okLabel: 'Restore',
|
|
1163
|
+
});
|
|
1164
|
+
if (!ok) return;
|
|
801
1165
|
try {
|
|
802
1166
|
const r = await api('POST', '/api/snapshot/restore', { file });
|
|
803
1167
|
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
804
1168
|
} catch (e) { toast(e.message, 'error'); }
|
|
805
1169
|
};
|
|
806
1170
|
|
|
807
|
-
/*
|
|
808
|
-
|
|
809
|
-
const repos = $$(
|
|
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);
|
|
810
1174
|
if (repos.length === 0) return toast('select at least one repo', 'error');
|
|
811
|
-
const workspace =
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
resetProgress(repos);
|
|
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);
|
|
816
1180
|
try {
|
|
817
|
-
const result = await streamNewSession({ repos, workspace });
|
|
1181
|
+
const result = await streamNewSession({ repos, workspace }, { progressRootId, resultElId });
|
|
818
1182
|
if (result.success) {
|
|
819
1183
|
const ws = result.workspace;
|
|
820
1184
|
const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
821
|
-
|
|
1185
|
+
if (resultEl) resultEl.textContent =
|
|
822
1186
|
`launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
|
|
823
1187
|
toast(`launched · ${ws.name}`);
|
|
1188
|
+
if (onSuccess) onSuccess(result);
|
|
824
1189
|
} else {
|
|
825
|
-
|
|
1190
|
+
if (resultEl) resultEl.textContent = `error: ${result.error}`;
|
|
826
1191
|
toast(result.error || 'new session failed', 'error');
|
|
827
1192
|
}
|
|
828
1193
|
await loadWorkspaces();
|
|
829
1194
|
} catch (e) {
|
|
830
|
-
|
|
1195
|
+
if (resultEl) resultEl.textContent = `error: ${e.message}`;
|
|
831
1196
|
toast(e.message, 'error');
|
|
832
|
-
} finally {
|
|
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'); }
|
|
833
1261
|
};
|
|
834
1262
|
|
|
835
1263
|
/* config save */
|
|
836
|
-
|
|
1264
|
+
const saveConfig = async () => {
|
|
837
1265
|
const next = readConfigFromForm();
|
|
838
1266
|
try {
|
|
839
1267
|
const cfg = await api('PUT', '/api/config', next);
|
|
@@ -842,13 +1270,32 @@ function wireUp() {
|
|
|
842
1270
|
renderRepoPicker();
|
|
843
1271
|
renderHeaderStatus();
|
|
844
1272
|
$('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
|
|
1273
|
+
setConfigDirty(false);
|
|
845
1274
|
toast('config saved');
|
|
846
1275
|
await loadWorkspaces();
|
|
847
1276
|
} catch (e) { toast(e.message, 'error'); }
|
|
848
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
|
+
};
|
|
849
1295
|
$('#addRepoBtn').onclick = () => {
|
|
850
1296
|
state.config.repos.push({ name: '', url: '', defaultSelected: false });
|
|
851
1297
|
renderConfig();
|
|
1298
|
+
setConfigDirty(true);
|
|
852
1299
|
};
|
|
853
1300
|
$('#reposTable').addEventListener('click', (ev) => {
|
|
854
1301
|
const rm = ev.target.closest('button[data-remove-repo]');
|
|
@@ -856,7 +1303,16 @@ function wireUp() {
|
|
|
856
1303
|
const idx = Number(rm.dataset.removeRepo);
|
|
857
1304
|
state.config.repos.splice(idx, 1);
|
|
858
1305
|
renderConfig();
|
|
1306
|
+
setConfigDirty(true);
|
|
859
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
|
+
}
|
|
860
1316
|
}
|
|
861
1317
|
|
|
862
1318
|
function startAutoRefresh() {
|
|
@@ -865,6 +1321,7 @@ function startAutoRefresh() {
|
|
|
865
1321
|
loadSessions().catch(() => {});
|
|
866
1322
|
loadRecent().catch(() => {});
|
|
867
1323
|
loadSnapshot().catch(() => {});
|
|
1324
|
+
pollHealth();
|
|
868
1325
|
}, 5000);
|
|
869
1326
|
}
|
|
870
1327
|
|
|
@@ -880,12 +1337,14 @@ function reRenderFavoritesIfNeeded() {
|
|
|
880
1337
|
|
|
881
1338
|
(async () => {
|
|
882
1339
|
restoreSidebar();
|
|
1340
|
+
restoreCardFolds();
|
|
883
1341
|
wireUp();
|
|
884
1342
|
try {
|
|
885
1343
|
await loadConfig();
|
|
886
1344
|
await refreshAll();
|
|
887
1345
|
selectTab(state.activeTab);
|
|
888
1346
|
startAutoRefresh();
|
|
1347
|
+
pollHealth();
|
|
889
1348
|
tickClock();
|
|
890
1349
|
state.clockTimer = setInterval(tickClock, 1000);
|
|
891
1350
|
} catch (e) {
|