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