@bakapiano/ccsm 0.4.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 +58 -3
- package/lib/favorites.js +73 -0
- package/lib/sessions.js +48 -8
- package/package.json +1 -1
- package/public/app.js +567 -276
- package/public/index.html +373 -163
- package/public/styles.css +1193 -125
- package/server.js +59 -9
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,151 +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
|
|
|
184
|
+
/* ─────────────────────────────────────────────────────────────
|
|
185
|
+
Render: recently closed
|
|
186
|
+
───────────────────────────────────────────────────────────── */
|
|
187
|
+
|
|
86
188
|
function renderRecent() {
|
|
87
189
|
const tb = $('#recentTable tbody');
|
|
88
190
|
tb.innerHTML = '';
|
|
89
191
|
const recent = state.recent || [];
|
|
90
192
|
for (const s of recent) {
|
|
193
|
+
const isFav = !!state.favorites[s.sessionId];
|
|
91
194
|
const tr = document.createElement('tr');
|
|
92
195
|
tr.innerHTML = `
|
|
93
|
-
<td
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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>
|
|
101
213
|
</td>
|
|
102
214
|
`;
|
|
215
|
+
tr.dataset.cwd = s.cwd || '';
|
|
216
|
+
tr.dataset.title = s.title || '';
|
|
217
|
+
tr.dataset.gitBranch = s.gitBranch || '';
|
|
103
218
|
tb.appendChild(tr);
|
|
104
219
|
}
|
|
105
|
-
$('#
|
|
106
|
-
|
|
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');
|
|
107
241
|
}
|
|
108
242
|
|
|
109
|
-
|
|
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
|
+
───────────────────────────────────────────────────────────── */
|
|
110
293
|
|
|
111
294
|
function renderSnapshot() {
|
|
112
295
|
const snap = state.snapshot;
|
|
113
296
|
if (!snap) {
|
|
114
|
-
$('#snapshotMeta').textContent = 'no snapshot yet';
|
|
297
|
+
$('#snapshotMeta').textContent = 'no snapshot saved yet';
|
|
115
298
|
$('#snapshotPreview').textContent = '';
|
|
116
299
|
return;
|
|
117
300
|
}
|
|
118
301
|
$('#snapshotMeta').textContent =
|
|
119
|
-
`${snap.sessions.length} session(s)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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');
|
|
124
307
|
|
|
125
308
|
const sel = $('#historySelect');
|
|
126
309
|
sel.innerHTML = '<option value="">history…</option>' +
|
|
127
|
-
state.history.map((h) =>
|
|
310
|
+
state.history.map((h) =>
|
|
311
|
+
`<option value="${escapeHtml(h.file)}">${escapeHtml(h.file.replace('.json', ''))}</option>`
|
|
312
|
+
).join('');
|
|
128
313
|
}
|
|
129
314
|
|
|
130
|
-
|
|
315
|
+
/* ─────────────────────────────────────────────────────────────
|
|
316
|
+
Render: workspaces
|
|
317
|
+
───────────────────────────────────────────────────────────── */
|
|
131
318
|
|
|
132
319
|
function renderWorkspaces() {
|
|
133
|
-
const
|
|
134
|
-
|
|
320
|
+
const grid = $('#workspaceList');
|
|
321
|
+
grid.innerHTML = '';
|
|
135
322
|
if (state.workspaces.length === 0) {
|
|
136
|
-
|
|
323
|
+
grid.innerHTML = '<div class="empty">No workspaces yet — the first launch will create one.</div>';
|
|
137
324
|
}
|
|
138
325
|
for (const w of state.workspaces) {
|
|
139
|
-
const
|
|
140
|
-
`<span class="
|
|
141
|
-
).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('');
|
|
142
329
|
const card = document.createElement('div');
|
|
143
330
|
card.className = 'workspace-card' + (w.inUse ? ' in-use' : '');
|
|
144
331
|
card.innerHTML = `
|
|
145
|
-
<div>
|
|
146
|
-
<div class="name">${escapeHtml(w.name)}
|
|
147
|
-
|
|
148
|
-
</div>
|
|
149
|
-
<div class="repos">${escapeHtml(w.path)}</div>
|
|
150
|
-
<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>
|
|
151
335
|
</div>
|
|
336
|
+
<div class="ws-path">${escapeHtml(w.path)}</div>
|
|
337
|
+
<div class="ws-repos">${repos}</div>
|
|
152
338
|
`;
|
|
153
|
-
|
|
339
|
+
grid.appendChild(card);
|
|
154
340
|
}
|
|
155
341
|
|
|
156
342
|
const sel = $('#workspaceSelect');
|
|
157
|
-
sel.innerHTML = '<option value="">
|
|
343
|
+
sel.innerHTML = '<option value="">auto — find or create unused</option>' +
|
|
158
344
|
state.workspaces.filter((w) => !w.inUse).map((w) =>
|
|
159
345
|
`<option value="${escapeHtml(w.name)}">${escapeHtml(w.name)}</option>`
|
|
160
346
|
).join('');
|
|
347
|
+
|
|
348
|
+
if (state.config) $('#workDirDisplay').textContent = state.config.workDir;
|
|
161
349
|
}
|
|
162
350
|
|
|
163
|
-
|
|
351
|
+
/* ─────────────────────────────────────────────────────────────
|
|
352
|
+
Render: repo picker
|
|
353
|
+
───────────────────────────────────────────────────────────── */
|
|
164
354
|
|
|
165
355
|
function renderRepoPicker() {
|
|
166
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
|
+
}
|
|
167
362
|
root.innerHTML = '';
|
|
168
|
-
for (const r of
|
|
169
|
-
const id = `repo_${r.name}`;
|
|
363
|
+
for (const r of repos) {
|
|
170
364
|
const chip = document.createElement('label');
|
|
171
|
-
chip.className = '
|
|
172
|
-
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)}`;
|
|
173
367
|
chip.querySelector('input').addEventListener('change', (e) => {
|
|
174
368
|
chip.classList.toggle('checked', e.target.checked);
|
|
175
369
|
});
|
|
@@ -177,7 +371,9 @@ function renderRepoPicker() {
|
|
|
177
371
|
}
|
|
178
372
|
}
|
|
179
373
|
|
|
180
|
-
|
|
374
|
+
/* ─────────────────────────────────────────────────────────────
|
|
375
|
+
Render: config form
|
|
376
|
+
───────────────────────────────────────────────────────────── */
|
|
181
377
|
|
|
182
378
|
function renderConfig() {
|
|
183
379
|
if (!state.config) return;
|
|
@@ -191,10 +387,12 @@ function renderConfig() {
|
|
|
191
387
|
$('#cfgBrowserMode').value =
|
|
192
388
|
state.config.browserMode ||
|
|
193
389
|
(state.config.autoOpenBrowser === false ? 'none' : 'app');
|
|
390
|
+
|
|
194
391
|
const termSel = $('#cfgTerminal');
|
|
195
392
|
termSel.innerHTML = (state.terminals || []).map((t) =>
|
|
196
|
-
`<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>`
|
|
197
394
|
).join('');
|
|
395
|
+
|
|
198
396
|
$('#cfgFinderPrompt').value = state.config.finderPrompt || '';
|
|
199
397
|
|
|
200
398
|
const tb = $('#reposTable tbody');
|
|
@@ -202,10 +400,10 @@ function renderConfig() {
|
|
|
202
400
|
(state.config.repos || []).forEach((r, idx) => {
|
|
203
401
|
const tr = document.createElement('tr');
|
|
204
402
|
tr.innerHTML = `
|
|
205
|
-
<td><input type="text" value="${escapeHtml(r.name)}" data-field="name" data-idx="${idx}"
|
|
206
|
-
<td><input type="text" value="${escapeHtml(r.url)}" data-field="url" data-idx="${idx}"
|
|
207
|
-
<td
|
|
208
|
-
<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>
|
|
209
407
|
`;
|
|
210
408
|
tb.appendChild(tr);
|
|
211
409
|
});
|
|
@@ -236,20 +434,29 @@ function readConfigFromForm() {
|
|
|
236
434
|
};
|
|
237
435
|
}
|
|
238
436
|
|
|
239
|
-
|
|
437
|
+
/* ─────────────────────────────────────────────────────────────
|
|
438
|
+
Header + footer status
|
|
439
|
+
───────────────────────────────────────────────────────────── */
|
|
240
440
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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';
|
|
245
449
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
renderRecent();
|
|
450
|
+
function tickClock() {
|
|
451
|
+
const t = new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
452
|
+
const el = $('#hdTime');
|
|
453
|
+
if (el) el.textContent = t;
|
|
251
454
|
}
|
|
252
455
|
|
|
456
|
+
/* ─────────────────────────────────────────────────────────────
|
|
457
|
+
Loaders
|
|
458
|
+
───────────────────────────────────────────────────────────── */
|
|
459
|
+
|
|
253
460
|
async function loadConfig() {
|
|
254
461
|
const [cfg, terminals] = await Promise.all([
|
|
255
462
|
api('GET', '/api/config'),
|
|
@@ -259,10 +466,56 @@ async function loadConfig() {
|
|
|
259
466
|
state.terminals = terminals.terminals;
|
|
260
467
|
renderConfig();
|
|
261
468
|
renderRepoPicker();
|
|
262
|
-
|
|
263
|
-
|
|
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 */ }
|
|
264
493
|
}
|
|
265
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
|
+
}
|
|
266
519
|
async function loadSnapshot() {
|
|
267
520
|
const r = await api('GET', '/api/snapshot');
|
|
268
521
|
state.snapshot = r.snapshot;
|
|
@@ -270,64 +523,235 @@ async function loadSnapshot() {
|
|
|
270
523
|
state.history = h.history;
|
|
271
524
|
renderSnapshot();
|
|
272
525
|
}
|
|
273
|
-
|
|
274
526
|
async function loadWorkspaces() {
|
|
275
527
|
const r = await api('GET', '/api/workspaces');
|
|
276
528
|
state.workspaces = r.workspaces;
|
|
277
529
|
renderWorkspaces();
|
|
278
530
|
}
|
|
279
|
-
|
|
280
531
|
async function refreshAll() {
|
|
281
|
-
await Promise.all([loadSessions(), loadRecent(), 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
|
+
}
|
|
282
645
|
}
|
|
283
646
|
|
|
284
|
-
|
|
647
|
+
/* ─────────────────────────────────────────────────────────────
|
|
648
|
+
Wiring
|
|
649
|
+
───────────────────────────────────────────────────────────── */
|
|
285
650
|
|
|
286
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
|
+
|
|
287
662
|
$('#refreshBtn').onclick = () => refreshAll().then(() => toast('refreshed'));
|
|
288
663
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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(() => {});
|
|
292
722
|
};
|
|
293
723
|
|
|
724
|
+
/* live sessions actions */
|
|
294
725
|
$('#sessionsTable').addEventListener('click', async (ev) => {
|
|
726
|
+
if (ev.target.closest('button[data-star]')) return;
|
|
295
727
|
const focusBtn = ev.target.closest('button[data-focus]');
|
|
296
728
|
if (focusBtn) {
|
|
297
729
|
const sessionId = focusBtn.dataset.focus;
|
|
298
730
|
focusBtn.disabled = true;
|
|
299
731
|
try {
|
|
300
732
|
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
301
|
-
if (r.ok && r.activated) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
toast(`no window for pid — chain: ${(r.chain||[]).map(c=>c.name).join('→')}`, 'error');
|
|
307
|
-
}
|
|
308
|
-
} catch (e) {
|
|
309
|
-
toast(e.message, 'error');
|
|
310
|
-
} finally {
|
|
311
|
-
focusBtn.disabled = false;
|
|
312
|
-
}
|
|
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; }
|
|
313
738
|
return;
|
|
314
739
|
}
|
|
315
|
-
const
|
|
316
|
-
if (!
|
|
317
|
-
const sessionId =
|
|
318
|
-
const cwd =
|
|
319
|
-
|
|
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;
|
|
320
745
|
try {
|
|
321
746
|
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
322
|
-
toast(`opening wt
|
|
323
|
-
} catch (e) {
|
|
324
|
-
|
|
325
|
-
} finally {
|
|
326
|
-
btn.disabled = false;
|
|
327
|
-
}
|
|
747
|
+
toast(`opening wt · ${sessionId.slice(0, 8)}…`);
|
|
748
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
749
|
+
finally { resumeBtn.disabled = false; }
|
|
328
750
|
});
|
|
329
751
|
|
|
752
|
+
/* recent continue */
|
|
330
753
|
$('#recentTable').addEventListener('click', async (ev) => {
|
|
754
|
+
if (ev.target.closest('button[data-star]')) return;
|
|
331
755
|
const btn = ev.target.closest('button[data-continue]');
|
|
332
756
|
if (!btn) return;
|
|
333
757
|
const sessionId = btn.dataset.continue;
|
|
@@ -335,25 +759,22 @@ function wireUp() {
|
|
|
335
759
|
btn.disabled = true;
|
|
336
760
|
try {
|
|
337
761
|
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
338
|
-
toast(`continuing ${sessionId.slice(0, 8)}…`);
|
|
762
|
+
toast(`continuing · ${sessionId.slice(0, 8)}…`);
|
|
339
763
|
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
340
764
|
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
341
|
-
} catch (e) {
|
|
342
|
-
|
|
343
|
-
} finally {
|
|
344
|
-
btn.disabled = false;
|
|
345
|
-
}
|
|
765
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
766
|
+
finally { btn.disabled = false; }
|
|
346
767
|
});
|
|
347
768
|
|
|
769
|
+
/* finder */
|
|
348
770
|
$('#finderBtn').onclick = async () => {
|
|
349
771
|
try {
|
|
350
772
|
await api('POST', '/api/sessions/finder');
|
|
351
773
|
toast('finder session launching in a new wt window');
|
|
352
|
-
} catch (e) {
|
|
353
|
-
toast(e.message, 'error');
|
|
354
|
-
}
|
|
774
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
355
775
|
};
|
|
356
776
|
|
|
777
|
+
/* snapshot */
|
|
357
778
|
$('#snapshotSaveBtn').onclick = async () => {
|
|
358
779
|
try {
|
|
359
780
|
const r = await api('POST', '/api/snapshot');
|
|
@@ -361,12 +782,9 @@ function wireUp() {
|
|
|
361
782
|
const h = await api('GET', '/api/snapshot/history');
|
|
362
783
|
state.history = h.history;
|
|
363
784
|
renderSnapshot();
|
|
364
|
-
toast(`saved
|
|
365
|
-
} catch (e) {
|
|
366
|
-
toast(e.message, 'error');
|
|
367
|
-
}
|
|
785
|
+
toast(`saved · ${r.snapshot.sessions.length} session(s)`);
|
|
786
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
368
787
|
};
|
|
369
|
-
|
|
370
788
|
$('#snapshotRestoreBtn').onclick = async () => {
|
|
371
789
|
const snap = state.snapshot;
|
|
372
790
|
if (!snap || !snap.sessions.length) return toast('no sessions in snapshot', 'error');
|
|
@@ -374,11 +792,8 @@ function wireUp() {
|
|
|
374
792
|
try {
|
|
375
793
|
const r = await api('POST', '/api/snapshot/restore');
|
|
376
794
|
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
377
|
-
} catch (e) {
|
|
378
|
-
toast(e.message, 'error');
|
|
379
|
-
}
|
|
795
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
380
796
|
};
|
|
381
|
-
|
|
382
797
|
$('#historyRestoreBtn').onclick = async () => {
|
|
383
798
|
const file = $('#historySelect').value;
|
|
384
799
|
if (!file) return toast('pick a history snapshot first', 'error');
|
|
@@ -386,11 +801,10 @@ function wireUp() {
|
|
|
386
801
|
try {
|
|
387
802
|
const r = await api('POST', '/api/snapshot/restore', { file });
|
|
388
803
|
toast(`launched ${r.restored.launched.length} / ${r.count}`);
|
|
389
|
-
} catch (e) {
|
|
390
|
-
toast(e.message, 'error');
|
|
391
|
-
}
|
|
804
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
392
805
|
};
|
|
393
806
|
|
|
807
|
+
/* new session */
|
|
394
808
|
$('#newSessionBtn').onclick = async () => {
|
|
395
809
|
const repos = $$('#repoPicker input:checked').map((i) => i.dataset.repo);
|
|
396
810
|
if (repos.length === 0) return toast('select at least one repo', 'error');
|
|
@@ -405,8 +819,8 @@ function wireUp() {
|
|
|
405
819
|
const ws = result.workspace;
|
|
406
820
|
const summary = (result.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
407
821
|
$('#newSessionResult').textContent =
|
|
408
|
-
`launched in ${ws.path}${result.created ? '
|
|
409
|
-
toast(`launched
|
|
822
|
+
`launched in ${ws.path}${result.created ? ' · newly created' : ''} — ${summary}`;
|
|
823
|
+
toast(`launched · ${ws.name}`);
|
|
410
824
|
} else {
|
|
411
825
|
$('#newSessionResult').textContent = `error: ${result.error}`;
|
|
412
826
|
toast(result.error || 'new session failed', 'error');
|
|
@@ -415,11 +829,10 @@ function wireUp() {
|
|
|
415
829
|
} catch (e) {
|
|
416
830
|
$('#newSessionResult').textContent = `error: ${e.message}`;
|
|
417
831
|
toast(e.message, 'error');
|
|
418
|
-
} finally {
|
|
419
|
-
btn.disabled = false;
|
|
420
|
-
}
|
|
832
|
+
} finally { btn.disabled = false; }
|
|
421
833
|
};
|
|
422
834
|
|
|
835
|
+
/* config save */
|
|
423
836
|
$('#saveConfigBtn').onclick = async () => {
|
|
424
837
|
const next = readConfigFromForm();
|
|
425
838
|
try {
|
|
@@ -427,19 +840,16 @@ function wireUp() {
|
|
|
427
840
|
state.config = cfg;
|
|
428
841
|
renderConfig();
|
|
429
842
|
renderRepoPicker();
|
|
430
|
-
|
|
843
|
+
renderHeaderStatus();
|
|
844
|
+
$('#configSavedAt').textContent = `saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`;
|
|
431
845
|
toast('config saved');
|
|
432
846
|
await loadWorkspaces();
|
|
433
|
-
} catch (e) {
|
|
434
|
-
toast(e.message, 'error');
|
|
435
|
-
}
|
|
847
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
436
848
|
};
|
|
437
|
-
|
|
438
849
|
$('#addRepoBtn').onclick = () => {
|
|
439
850
|
state.config.repos.push({ name: '', url: '', defaultSelected: false });
|
|
440
851
|
renderConfig();
|
|
441
852
|
};
|
|
442
|
-
|
|
443
853
|
$('#reposTable').addEventListener('click', (ev) => {
|
|
444
854
|
const rm = ev.target.closest('button[data-remove-repo]');
|
|
445
855
|
if (!rm) return;
|
|
@@ -449,155 +859,36 @@ function wireUp() {
|
|
|
449
859
|
});
|
|
450
860
|
}
|
|
451
861
|
|
|
452
|
-
// ---- auto refresh ----
|
|
453
|
-
|
|
454
862
|
function startAutoRefresh() {
|
|
455
|
-
|
|
863
|
+
if (state.autoTimer) clearInterval(state.autoTimer);
|
|
456
864
|
state.autoTimer = setInterval(() => {
|
|
457
865
|
loadSessions().catch(() => {});
|
|
458
866
|
loadRecent().catch(() => {});
|
|
459
867
|
loadSnapshot().catch(() => {});
|
|
460
868
|
}, 5000);
|
|
461
869
|
}
|
|
462
|
-
function stopAutoRefresh() {
|
|
463
|
-
if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; }
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ---- NDJSON streaming for /api/sessions/new ----
|
|
467
|
-
|
|
468
|
-
function resetProgress(repoNames) {
|
|
469
|
-
const root = $('#newSessionProgress');
|
|
470
|
-
root.innerHTML = '';
|
|
471
|
-
for (const r of repoNames) {
|
|
472
|
-
const el = document.createElement('div');
|
|
473
|
-
el.className = 'progress-item';
|
|
474
|
-
el.dataset.repo = r;
|
|
475
|
-
el.innerHTML = `
|
|
476
|
-
<div class="head">
|
|
477
|
-
<span class="name">${escapeHtml(r)}</span>
|
|
478
|
-
<span class="phase">queued</span>
|
|
479
|
-
<span class="pct"></span>
|
|
480
|
-
</div>
|
|
481
|
-
<div class="progress-bar"><div class="fill"></div></div>
|
|
482
|
-
<div class="detail"></div>
|
|
483
|
-
`;
|
|
484
|
-
root.appendChild(el);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function progressItem(repo) {
|
|
489
|
-
return document.querySelector(`#newSessionProgress .progress-item[data-repo="${CSS.escape(repo)}"]`);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function setProgress(repo, { phase, percent, detail, state, indeterminate } = {}) {
|
|
493
|
-
const el = progressItem(repo);
|
|
494
|
-
if (!el) return;
|
|
495
|
-
if (state) {
|
|
496
|
-
el.classList.remove('ok', 'error');
|
|
497
|
-
if (state === 'ok' || state === 'error') el.classList.add(state);
|
|
498
|
-
}
|
|
499
|
-
if (phase != null) el.querySelector('.phase').textContent = phase;
|
|
500
|
-
if (percent != null) {
|
|
501
|
-
el.querySelector('.pct').textContent = `${percent}%`;
|
|
502
|
-
el.querySelector('.fill').style.width = `${percent}%`;
|
|
503
|
-
el.querySelector('.fill').classList.remove('indeterminate');
|
|
504
|
-
}
|
|
505
|
-
if (indeterminate) {
|
|
506
|
-
el.querySelector('.fill').classList.add('indeterminate');
|
|
507
|
-
el.querySelector('.pct').textContent = '';
|
|
508
|
-
}
|
|
509
|
-
if (detail != null) el.querySelector('.detail').textContent = detail;
|
|
510
|
-
}
|
|
511
870
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
body: JSON.stringify(body),
|
|
517
|
-
});
|
|
518
|
-
if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
|
|
519
|
-
const j = await res.json();
|
|
520
|
-
throw new Error(j.error || `HTTP ${res.status}`);
|
|
521
|
-
}
|
|
522
|
-
const reader = res.body.getReader();
|
|
523
|
-
const decoder = new TextDecoder();
|
|
524
|
-
let buf = '';
|
|
525
|
-
let final = null;
|
|
526
|
-
while (true) {
|
|
527
|
-
const { done, value } = await reader.read();
|
|
528
|
-
if (done) break;
|
|
529
|
-
buf += decoder.decode(value, { stream: true });
|
|
530
|
-
const lines = buf.split('\n');
|
|
531
|
-
buf = lines.pop();
|
|
532
|
-
for (const line of lines) {
|
|
533
|
-
if (!line.trim()) continue;
|
|
534
|
-
let event;
|
|
535
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
536
|
-
handleNewSessionEvent(event);
|
|
537
|
-
if (event.type === 'done') final = event;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
if (buf.trim()) {
|
|
541
|
-
try {
|
|
542
|
-
const event = JSON.parse(buf);
|
|
543
|
-
handleNewSessionEvent(event);
|
|
544
|
-
if (event.type === 'done') final = event;
|
|
545
|
-
} catch {}
|
|
546
|
-
}
|
|
547
|
-
return final || { success: false, error: 'stream ended unexpectedly' };
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function handleNewSessionEvent(ev) {
|
|
551
|
-
switch (ev.type) {
|
|
552
|
-
case 'workspace':
|
|
553
|
-
$('#newSessionResult').textContent =
|
|
554
|
-
`workspace: ${ev.workspace.path}${ev.created ? ' (new)' : ''}`;
|
|
555
|
-
break;
|
|
556
|
-
case 'clone-start':
|
|
557
|
-
setProgress(ev.repo, { phase: 'starting', indeterminate: true });
|
|
558
|
-
break;
|
|
559
|
-
case 'clone-progress':
|
|
560
|
-
setProgress(ev.repo, {
|
|
561
|
-
phase: ev.phase,
|
|
562
|
-
percent: ev.percent,
|
|
563
|
-
detail: ev.detail || (ev.current != null ? `${ev.current}/${ev.total}` : ''),
|
|
564
|
-
});
|
|
565
|
-
break;
|
|
566
|
-
case 'clone-end':
|
|
567
|
-
if (ev.ok) {
|
|
568
|
-
setProgress(ev.repo, {
|
|
569
|
-
phase: ev.action || 'done',
|
|
570
|
-
percent: 100,
|
|
571
|
-
detail: ev.path || '',
|
|
572
|
-
state: 'ok',
|
|
573
|
-
});
|
|
574
|
-
} else {
|
|
575
|
-
setProgress(ev.repo, {
|
|
576
|
-
phase: 'error',
|
|
577
|
-
detail: ev.error,
|
|
578
|
-
state: 'error',
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
break;
|
|
582
|
-
case 'launched':
|
|
583
|
-
$('#newSessionResult').textContent =
|
|
584
|
-
`terminal launching — pid ${ev.launched.pid} (${ev.launched.terminal})`;
|
|
585
|
-
break;
|
|
586
|
-
case 'done':
|
|
587
|
-
// handled by caller
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
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();
|
|
590
875
|
}
|
|
591
876
|
|
|
592
|
-
|
|
877
|
+
/* ─────────────────────────────────────────────────────────────
|
|
878
|
+
Boot
|
|
879
|
+
───────────────────────────────────────────────────────────── */
|
|
593
880
|
|
|
594
881
|
(async () => {
|
|
882
|
+
restoreSidebar();
|
|
595
883
|
wireUp();
|
|
596
884
|
try {
|
|
597
885
|
await loadConfig();
|
|
598
886
|
await refreshAll();
|
|
887
|
+
selectTab(state.activeTab);
|
|
599
888
|
startAutoRefresh();
|
|
889
|
+
tickClock();
|
|
890
|
+
state.clockTimer = setInterval(tickClock, 1000);
|
|
600
891
|
} catch (e) {
|
|
601
|
-
toast('initial load failed
|
|
892
|
+
toast('initial load failed · ' + e.message, 'error');
|
|
602
893
|
}
|
|
603
894
|
})();
|