@bakapiano/ccsm 0.22.2 → 0.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +274 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +233 -231
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +176 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +592 -592
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +187 -15
- package/public/js/components/TerminalResizeDebouncer.js +126 -0
- package/public/js/components/XtermTerminal.js +148 -14
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +713 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +100 -100
- package/public/js/state.js +335 -335
- package/public/manifest.webmanifest +25 -0
- package/public/setup/index.html +567 -0
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1807 -1807
package/public/js/api.js
CHANGED
|
@@ -1,254 +1,254 @@
|
|
|
1
|
-
// Fetch wrapper + every loader. Loaders push into signals from ./state.js.
|
|
2
|
-
// Cross-origin (hosted frontend → local backend) flows through httpBase().
|
|
3
|
-
|
|
4
|
-
import { signal } from '@preact/signals';
|
|
5
|
-
import * as S from './state.js';
|
|
6
|
-
import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess, estimateTermSize } from './backend.js';
|
|
7
|
-
|
|
8
|
-
// Global pending-approval signal. Flipped to true whenever any /api
|
|
9
|
-
// call returns 403 {pending:true}; PendingApprovalOverlay watches this
|
|
10
|
-
// and shows the blocking screen. We also stash the server's record so
|
|
11
|
-
// the overlay can display "we recorded you at HH:MM" detail.
|
|
12
|
-
export const pendingDevice = signal(null);
|
|
13
|
-
|
|
14
|
-
export async function api(method, url, body) {
|
|
15
|
-
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
16
|
-
// When a remote token is configured (Remote page set it OR the page
|
|
17
|
-
// was loaded with ?token= and we stashed it in localStorage), attach
|
|
18
|
-
// it to every API call. The server middleware lets loopback Hosts
|
|
19
|
-
// through without the token; for tunnel-served pages this is the
|
|
20
|
-
// only way past the 401.
|
|
21
|
-
const tok = getToken();
|
|
22
|
-
if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
|
|
23
|
-
// Always send our device id when one exists in localStorage. The host
|
|
24
|
-
// browser at localhost doesn't strictly need it (loopback bypass),
|
|
25
|
-
// but harmless — the server simply records lastSeen for it. Required
|
|
26
|
-
// for any tunnel-served page to clear the device-approval gate.
|
|
27
|
-
const dev = getDeviceId();
|
|
28
|
-
if (dev) opts.headers['X-Device-Id'] = dev;
|
|
29
|
-
// 4-digit identification code (see getDeviceCode in backend.js).
|
|
30
|
-
// Server stores it on first sight; the Remote page renders it
|
|
31
|
-
// alongside each pending device so the operator can confirm "yes,
|
|
32
|
-
// this is the request I just made on my phone" before approving.
|
|
33
|
-
const code = getDeviceCode();
|
|
34
|
-
if (code) opts.headers['X-Device-Code'] = code;
|
|
35
|
-
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
36
|
-
const r = await fetch(httpBase() + url, opts);
|
|
37
|
-
const text = await r.text();
|
|
38
|
-
let json;
|
|
39
|
-
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
40
|
-
if (!r.ok) {
|
|
41
|
-
// Surface device-approval pending state. Only matters on remote
|
|
42
|
-
// tabs — host's loopback browser never gets a 401/403 from these
|
|
43
|
-
// checks.
|
|
44
|
-
if (isRemoteAccess()) {
|
|
45
|
-
if (r.status === 403 && json && (json.pending || json.rejected)) {
|
|
46
|
-
// Merge into the existing pendingDevice rather than overwriting
|
|
47
|
-
// so the "we recorded you at HH:MM" detail (only present on the
|
|
48
|
-
// initial /me hit, not subsequent gate 403s) survives. Without
|
|
49
|
-
// this merge, the first failing /api/sessions tick after the
|
|
50
|
-
// overlay mounts wipes the firstSeen timestamp and the copy
|
|
51
|
-
// reverts to a generic "The host machine got your request".
|
|
52
|
-
const prev = pendingDevice.value || {};
|
|
53
|
-
pendingDevice.value = { ...prev, ...json, at: Date.now() };
|
|
54
|
-
} else if (r.status === 401) {
|
|
55
|
-
// Server doesn't recognise our device — either fresh page load
|
|
56
|
-
// (no /api/devices/me hit yet) or our record got pruned (24h
|
|
57
|
-
// pending TTL) AND our token no longer matches the host's
|
|
58
|
-
// current one. PendingApprovalOverlay's /me poll will try to
|
|
59
|
-
// re-register; on token mismatch /me itself 401s and the
|
|
60
|
-
// overlay flips into "token expired" state. We just nudge the
|
|
61
|
-
// overlay alive here.
|
|
62
|
-
const prev = pendingDevice.value || {};
|
|
63
|
-
pendingDevice.value = { ...prev, pending: true, at: Date.now() };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
throw new Error(json.error || `HTTP ${r.status}`);
|
|
67
|
-
}
|
|
68
|
-
// PendingApprovalOverlay clears pendingDevice itself based on the
|
|
69
|
-
// /api/devices/me body (which can return 200 with status:'pending'
|
|
70
|
-
// since that endpoint is gate-exempt). Doing an auto-clear here on
|
|
71
|
-
// any 2xx would race the overlay's poll and dismiss it prematurely.
|
|
72
|
-
return json;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function loadConfig() {
|
|
76
|
-
const [cfg, caps] = await Promise.all([
|
|
77
|
-
api('GET', '/api/config'),
|
|
78
|
-
api('GET', '/api/capabilities').catch(() => ({ webTerminal: false })),
|
|
79
|
-
]);
|
|
80
|
-
S.config.value = cfg;
|
|
81
|
-
S.capabilities.value = caps;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Update an existing CLI by id. patch is shallow-merged into the record.
|
|
85
|
-
export async function updateCli(id, patch) {
|
|
86
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
87
|
-
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
88
|
-
// Built-in CLIs lock down structural fields (id + builtin flag) but
|
|
89
|
-
// allow command edits — users routinely need to point at an absolute
|
|
90
|
-
// path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
|
|
91
|
-
// when the bare name isn't on the spawn-time PATH.
|
|
92
|
-
if (target?.builtin) {
|
|
93
|
-
delete patch.id;
|
|
94
|
-
delete patch.builtin;
|
|
95
|
-
}
|
|
96
|
-
const toArr = (v, fallback) => Array.isArray(v) ? v :
|
|
97
|
-
typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : fallback;
|
|
98
|
-
const next = {
|
|
99
|
-
...cfg,
|
|
100
|
-
clis: (cfg.clis || []).map((c) => c.id === id ? {
|
|
101
|
-
...c, ...patch,
|
|
102
|
-
args: toArr(patch.args, c.args),
|
|
103
|
-
shell: ['direct', 'pwsh', 'cmd'].includes(patch.shell ?? c.shell) ? (patch.shell ?? c.shell) : 'direct',
|
|
104
|
-
} : c),
|
|
105
|
-
};
|
|
106
|
-
const saved = await api('PUT', '/api/config', next);
|
|
107
|
-
S.config.value = saved;
|
|
108
|
-
return id;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Probe a (possibly-unsaved) CLI config: spawn its command with
|
|
112
|
-
// `--version`, capture output, see if it looks like the claimed type.
|
|
113
|
-
// `args` is intentionally ignored server-side — runtime flags can
|
|
114
|
-
// disturb a quick probe.
|
|
115
|
-
export async function testCli({ command, shell, type }) {
|
|
116
|
-
return api('POST', '/api/clis/test', { command, shell, type });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function deleteCli(id) {
|
|
120
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
121
|
-
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
122
|
-
if (target?.builtin) throw new Error(`"${target.name}" is built-in and can't be deleted`);
|
|
123
|
-
const clis = (cfg.clis || []).filter((c) => c.id !== id);
|
|
124
|
-
if (clis.length === 0) throw new Error('cannot delete the last CLI');
|
|
125
|
-
const next = { ...cfg, clis };
|
|
126
|
-
if (next.defaultCliId === id) next.defaultCliId = clis[0].id;
|
|
127
|
-
const saved = await api('PUT', '/api/config', next);
|
|
128
|
-
S.config.value = saved;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function updateRepo(name, patch) {
|
|
132
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
133
|
-
const next = {
|
|
134
|
-
...cfg,
|
|
135
|
-
repos: (cfg.repos || []).map((r) => r.name === name ? {
|
|
136
|
-
...r,
|
|
137
|
-
name: (patch.name ?? r.name).trim(),
|
|
138
|
-
url: (patch.url ?? r.url).trim(),
|
|
139
|
-
defaultSelected: patch.defaultSelected ?? r.defaultSelected,
|
|
140
|
-
} : r),
|
|
141
|
-
};
|
|
142
|
-
const saved = await api('PUT', '/api/config', next);
|
|
143
|
-
S.config.value = saved;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export async function deleteRepo(name) {
|
|
147
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
148
|
-
const next = { ...cfg, repos: (cfg.repos || []).filter((r) => r.name !== name) };
|
|
149
|
-
const saved = await api('PUT', '/api/config', next);
|
|
150
|
-
S.config.value = saved;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function setDefaultCli(id) {
|
|
154
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
155
|
-
const saved = await api('PUT', '/api/config', { ...cfg, defaultCliId: id });
|
|
156
|
-
S.config.value = saved;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Add a new CLI to config.clis and return its id. Generates a fresh id
|
|
160
|
-
// from the command name + an integer suffix when collisions exist.
|
|
161
|
-
export async function createCli({ name, command, args, shell, type }) {
|
|
162
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
163
|
-
const base = (name || command || 'cli').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cli';
|
|
164
|
-
let id = base, n = 1;
|
|
165
|
-
while ((cfg.clis || []).some((c) => c.id === id)) { id = `${base}-${++n}`; }
|
|
166
|
-
const toArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : []);
|
|
167
|
-
const next = {
|
|
168
|
-
...cfg,
|
|
169
|
-
clis: [...(cfg.clis || []), {
|
|
170
|
-
id,
|
|
171
|
-
name: (name || command || id).trim(),
|
|
172
|
-
command: (command || '').trim(),
|
|
173
|
-
args: toArr(args),
|
|
174
|
-
shell: ['direct', 'pwsh', 'cmd'].includes(shell) ? shell : 'direct',
|
|
175
|
-
type: ['claude', 'codex', 'copilot', 'other'].includes(type) ? type : 'other',
|
|
176
|
-
}],
|
|
177
|
-
};
|
|
178
|
-
const saved = await api('PUT', '/api/config', next);
|
|
179
|
-
S.config.value = saved;
|
|
180
|
-
return id;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Add a new repo to config.repos. Repos are addressed by name (which must
|
|
184
|
-
// be unique). Returns the name on success, throws on duplicate.
|
|
185
|
-
export async function createRepo({ name, url, defaultSelected }) {
|
|
186
|
-
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
187
|
-
const cleanName = (name || '').trim();
|
|
188
|
-
const cleanUrl = (url || '').trim();
|
|
189
|
-
if (!cleanName) throw new Error('repo name required');
|
|
190
|
-
if (!cleanUrl) throw new Error('repo url required');
|
|
191
|
-
if ((cfg.repos || []).some((r) => r.name === cleanName)) {
|
|
192
|
-
throw new Error(`repo "${cleanName}" already exists`);
|
|
193
|
-
}
|
|
194
|
-
const next = {
|
|
195
|
-
...cfg,
|
|
196
|
-
repos: [...(cfg.repos || []), {
|
|
197
|
-
name: cleanName,
|
|
198
|
-
url: cleanUrl,
|
|
199
|
-
defaultSelected: !!defaultSelected,
|
|
200
|
-
}],
|
|
201
|
-
};
|
|
202
|
-
const saved = await api('PUT', '/api/config', next);
|
|
203
|
-
S.config.value = saved;
|
|
204
|
-
return cleanName;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export async function loadSessions() {
|
|
208
|
-
const r = await api('GET', '/api/sessions');
|
|
209
|
-
S.sessions.value = r.sessions || [];
|
|
210
|
-
try { localStorage.setItem('ccsm.sessions-cache', JSON.stringify(S.sessions.value)); } catch {}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export async function loadFolders() {
|
|
214
|
-
const r = await api('GET', '/api/folders');
|
|
215
|
-
S.folders.value = (r.folders || []).sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
216
|
-
try { localStorage.setItem('ccsm.folders-cache', JSON.stringify(S.folders.value)); } catch {}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export async function createFolder(name) {
|
|
220
|
-
const r = await api('POST', '/api/folders', { name });
|
|
221
|
-
await loadFolders();
|
|
222
|
-
return r.folder;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function renameFolder(id, name) {
|
|
226
|
-
const r = await api('PUT', `/api/folders/${id}`, { name });
|
|
227
|
-
await loadFolders();
|
|
228
|
-
return r.folder;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function deleteFolder(id) {
|
|
232
|
-
await api('DELETE', `/api/folders/${id}`);
|
|
233
|
-
await Promise.all([loadFolders(), loadSessions()]);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export async function reorderFolders(ids) {
|
|
237
|
-
const r = await api('POST', '/api/folders/reorder', { ids });
|
|
238
|
-
await loadFolders();
|
|
239
|
-
return r.folders;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export async function setSessionFolder(sessionId, folderId) {
|
|
243
|
-
await api('PUT', `/api/sessions/${sessionId}`, { folderId: folderId || null });
|
|
244
|
-
await loadSessions();
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export async function reorderSessions(folderId, ids) {
|
|
248
|
-
await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
|
|
249
|
-
await loadSessions();
|
|
250
|
-
}
|
|
251
|
-
|
|
1
|
+
// Fetch wrapper + every loader. Loaders push into signals from ./state.js.
|
|
2
|
+
// Cross-origin (hosted frontend → local backend) flows through httpBase().
|
|
3
|
+
|
|
4
|
+
import { signal } from '@preact/signals';
|
|
5
|
+
import * as S from './state.js';
|
|
6
|
+
import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess, estimateTermSize } from './backend.js';
|
|
7
|
+
|
|
8
|
+
// Global pending-approval signal. Flipped to true whenever any /api
|
|
9
|
+
// call returns 403 {pending:true}; PendingApprovalOverlay watches this
|
|
10
|
+
// and shows the blocking screen. We also stash the server's record so
|
|
11
|
+
// the overlay can display "we recorded you at HH:MM" detail.
|
|
12
|
+
export const pendingDevice = signal(null);
|
|
13
|
+
|
|
14
|
+
export async function api(method, url, body) {
|
|
15
|
+
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
16
|
+
// When a remote token is configured (Remote page set it OR the page
|
|
17
|
+
// was loaded with ?token= and we stashed it in localStorage), attach
|
|
18
|
+
// it to every API call. The server middleware lets loopback Hosts
|
|
19
|
+
// through without the token; for tunnel-served pages this is the
|
|
20
|
+
// only way past the 401.
|
|
21
|
+
const tok = getToken();
|
|
22
|
+
if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
|
|
23
|
+
// Always send our device id when one exists in localStorage. The host
|
|
24
|
+
// browser at localhost doesn't strictly need it (loopback bypass),
|
|
25
|
+
// but harmless — the server simply records lastSeen for it. Required
|
|
26
|
+
// for any tunnel-served page to clear the device-approval gate.
|
|
27
|
+
const dev = getDeviceId();
|
|
28
|
+
if (dev) opts.headers['X-Device-Id'] = dev;
|
|
29
|
+
// 4-digit identification code (see getDeviceCode in backend.js).
|
|
30
|
+
// Server stores it on first sight; the Remote page renders it
|
|
31
|
+
// alongside each pending device so the operator can confirm "yes,
|
|
32
|
+
// this is the request I just made on my phone" before approving.
|
|
33
|
+
const code = getDeviceCode();
|
|
34
|
+
if (code) opts.headers['X-Device-Code'] = code;
|
|
35
|
+
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
36
|
+
const r = await fetch(httpBase() + url, opts);
|
|
37
|
+
const text = await r.text();
|
|
38
|
+
let json;
|
|
39
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
40
|
+
if (!r.ok) {
|
|
41
|
+
// Surface device-approval pending state. Only matters on remote
|
|
42
|
+
// tabs — host's loopback browser never gets a 401/403 from these
|
|
43
|
+
// checks.
|
|
44
|
+
if (isRemoteAccess()) {
|
|
45
|
+
if (r.status === 403 && json && (json.pending || json.rejected)) {
|
|
46
|
+
// Merge into the existing pendingDevice rather than overwriting
|
|
47
|
+
// so the "we recorded you at HH:MM" detail (only present on the
|
|
48
|
+
// initial /me hit, not subsequent gate 403s) survives. Without
|
|
49
|
+
// this merge, the first failing /api/sessions tick after the
|
|
50
|
+
// overlay mounts wipes the firstSeen timestamp and the copy
|
|
51
|
+
// reverts to a generic "The host machine got your request".
|
|
52
|
+
const prev = pendingDevice.value || {};
|
|
53
|
+
pendingDevice.value = { ...prev, ...json, at: Date.now() };
|
|
54
|
+
} else if (r.status === 401) {
|
|
55
|
+
// Server doesn't recognise our device — either fresh page load
|
|
56
|
+
// (no /api/devices/me hit yet) or our record got pruned (24h
|
|
57
|
+
// pending TTL) AND our token no longer matches the host's
|
|
58
|
+
// current one. PendingApprovalOverlay's /me poll will try to
|
|
59
|
+
// re-register; on token mismatch /me itself 401s and the
|
|
60
|
+
// overlay flips into "token expired" state. We just nudge the
|
|
61
|
+
// overlay alive here.
|
|
62
|
+
const prev = pendingDevice.value || {};
|
|
63
|
+
pendingDevice.value = { ...prev, pending: true, at: Date.now() };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(json.error || `HTTP ${r.status}`);
|
|
67
|
+
}
|
|
68
|
+
// PendingApprovalOverlay clears pendingDevice itself based on the
|
|
69
|
+
// /api/devices/me body (which can return 200 with status:'pending'
|
|
70
|
+
// since that endpoint is gate-exempt). Doing an auto-clear here on
|
|
71
|
+
// any 2xx would race the overlay's poll and dismiss it prematurely.
|
|
72
|
+
return json;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadConfig() {
|
|
76
|
+
const [cfg, caps] = await Promise.all([
|
|
77
|
+
api('GET', '/api/config'),
|
|
78
|
+
api('GET', '/api/capabilities').catch(() => ({ webTerminal: false })),
|
|
79
|
+
]);
|
|
80
|
+
S.config.value = cfg;
|
|
81
|
+
S.capabilities.value = caps;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Update an existing CLI by id. patch is shallow-merged into the record.
|
|
85
|
+
export async function updateCli(id, patch) {
|
|
86
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
87
|
+
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
88
|
+
// Built-in CLIs lock down structural fields (id + builtin flag) but
|
|
89
|
+
// allow command edits — users routinely need to point at an absolute
|
|
90
|
+
// path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
|
|
91
|
+
// when the bare name isn't on the spawn-time PATH.
|
|
92
|
+
if (target?.builtin) {
|
|
93
|
+
delete patch.id;
|
|
94
|
+
delete patch.builtin;
|
|
95
|
+
}
|
|
96
|
+
const toArr = (v, fallback) => Array.isArray(v) ? v :
|
|
97
|
+
typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : fallback;
|
|
98
|
+
const next = {
|
|
99
|
+
...cfg,
|
|
100
|
+
clis: (cfg.clis || []).map((c) => c.id === id ? {
|
|
101
|
+
...c, ...patch,
|
|
102
|
+
args: toArr(patch.args, c.args),
|
|
103
|
+
shell: ['direct', 'pwsh', 'cmd'].includes(patch.shell ?? c.shell) ? (patch.shell ?? c.shell) : 'direct',
|
|
104
|
+
} : c),
|
|
105
|
+
};
|
|
106
|
+
const saved = await api('PUT', '/api/config', next);
|
|
107
|
+
S.config.value = saved;
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Probe a (possibly-unsaved) CLI config: spawn its command with
|
|
112
|
+
// `--version`, capture output, see if it looks like the claimed type.
|
|
113
|
+
// `args` is intentionally ignored server-side — runtime flags can
|
|
114
|
+
// disturb a quick probe.
|
|
115
|
+
export async function testCli({ command, shell, type }) {
|
|
116
|
+
return api('POST', '/api/clis/test', { command, shell, type });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function deleteCli(id) {
|
|
120
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
121
|
+
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
122
|
+
if (target?.builtin) throw new Error(`"${target.name}" is built-in and can't be deleted`);
|
|
123
|
+
const clis = (cfg.clis || []).filter((c) => c.id !== id);
|
|
124
|
+
if (clis.length === 0) throw new Error('cannot delete the last CLI');
|
|
125
|
+
const next = { ...cfg, clis };
|
|
126
|
+
if (next.defaultCliId === id) next.defaultCliId = clis[0].id;
|
|
127
|
+
const saved = await api('PUT', '/api/config', next);
|
|
128
|
+
S.config.value = saved;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function updateRepo(name, patch) {
|
|
132
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
133
|
+
const next = {
|
|
134
|
+
...cfg,
|
|
135
|
+
repos: (cfg.repos || []).map((r) => r.name === name ? {
|
|
136
|
+
...r,
|
|
137
|
+
name: (patch.name ?? r.name).trim(),
|
|
138
|
+
url: (patch.url ?? r.url).trim(),
|
|
139
|
+
defaultSelected: patch.defaultSelected ?? r.defaultSelected,
|
|
140
|
+
} : r),
|
|
141
|
+
};
|
|
142
|
+
const saved = await api('PUT', '/api/config', next);
|
|
143
|
+
S.config.value = saved;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function deleteRepo(name) {
|
|
147
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
148
|
+
const next = { ...cfg, repos: (cfg.repos || []).filter((r) => r.name !== name) };
|
|
149
|
+
const saved = await api('PUT', '/api/config', next);
|
|
150
|
+
S.config.value = saved;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function setDefaultCli(id) {
|
|
154
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
155
|
+
const saved = await api('PUT', '/api/config', { ...cfg, defaultCliId: id });
|
|
156
|
+
S.config.value = saved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Add a new CLI to config.clis and return its id. Generates a fresh id
|
|
160
|
+
// from the command name + an integer suffix when collisions exist.
|
|
161
|
+
export async function createCli({ name, command, args, shell, type }) {
|
|
162
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
163
|
+
const base = (name || command || 'cli').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cli';
|
|
164
|
+
let id = base, n = 1;
|
|
165
|
+
while ((cfg.clis || []).some((c) => c.id === id)) { id = `${base}-${++n}`; }
|
|
166
|
+
const toArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : []);
|
|
167
|
+
const next = {
|
|
168
|
+
...cfg,
|
|
169
|
+
clis: [...(cfg.clis || []), {
|
|
170
|
+
id,
|
|
171
|
+
name: (name || command || id).trim(),
|
|
172
|
+
command: (command || '').trim(),
|
|
173
|
+
args: toArr(args),
|
|
174
|
+
shell: ['direct', 'pwsh', 'cmd'].includes(shell) ? shell : 'direct',
|
|
175
|
+
type: ['claude', 'codex', 'copilot', 'other'].includes(type) ? type : 'other',
|
|
176
|
+
}],
|
|
177
|
+
};
|
|
178
|
+
const saved = await api('PUT', '/api/config', next);
|
|
179
|
+
S.config.value = saved;
|
|
180
|
+
return id;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add a new repo to config.repos. Repos are addressed by name (which must
|
|
184
|
+
// be unique). Returns the name on success, throws on duplicate.
|
|
185
|
+
export async function createRepo({ name, url, defaultSelected }) {
|
|
186
|
+
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
187
|
+
const cleanName = (name || '').trim();
|
|
188
|
+
const cleanUrl = (url || '').trim();
|
|
189
|
+
if (!cleanName) throw new Error('repo name required');
|
|
190
|
+
if (!cleanUrl) throw new Error('repo url required');
|
|
191
|
+
if ((cfg.repos || []).some((r) => r.name === cleanName)) {
|
|
192
|
+
throw new Error(`repo "${cleanName}" already exists`);
|
|
193
|
+
}
|
|
194
|
+
const next = {
|
|
195
|
+
...cfg,
|
|
196
|
+
repos: [...(cfg.repos || []), {
|
|
197
|
+
name: cleanName,
|
|
198
|
+
url: cleanUrl,
|
|
199
|
+
defaultSelected: !!defaultSelected,
|
|
200
|
+
}],
|
|
201
|
+
};
|
|
202
|
+
const saved = await api('PUT', '/api/config', next);
|
|
203
|
+
S.config.value = saved;
|
|
204
|
+
return cleanName;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function loadSessions() {
|
|
208
|
+
const r = await api('GET', '/api/sessions');
|
|
209
|
+
S.sessions.value = r.sessions || [];
|
|
210
|
+
try { localStorage.setItem('ccsm.sessions-cache', JSON.stringify(S.sessions.value)); } catch {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function loadFolders() {
|
|
214
|
+
const r = await api('GET', '/api/folders');
|
|
215
|
+
S.folders.value = (r.folders || []).sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
216
|
+
try { localStorage.setItem('ccsm.folders-cache', JSON.stringify(S.folders.value)); } catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function createFolder(name) {
|
|
220
|
+
const r = await api('POST', '/api/folders', { name });
|
|
221
|
+
await loadFolders();
|
|
222
|
+
return r.folder;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function renameFolder(id, name) {
|
|
226
|
+
const r = await api('PUT', `/api/folders/${id}`, { name });
|
|
227
|
+
await loadFolders();
|
|
228
|
+
return r.folder;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function deleteFolder(id) {
|
|
232
|
+
await api('DELETE', `/api/folders/${id}`);
|
|
233
|
+
await Promise.all([loadFolders(), loadSessions()]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function reorderFolders(ids) {
|
|
237
|
+
const r = await api('POST', '/api/folders/reorder', { ids });
|
|
238
|
+
await loadFolders();
|
|
239
|
+
return r.folders;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function setSessionFolder(sessionId, folderId) {
|
|
243
|
+
await api('PUT', `/api/sessions/${sessionId}`, { folderId: folderId || null });
|
|
244
|
+
await loadSessions();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function reorderSessions(folderId, ids) {
|
|
248
|
+
await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
|
|
249
|
+
await loadSessions();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
252
|
export async function setSessionTitle(sessionId, title) {
|
|
253
253
|
await api('PUT', `/api/sessions/${sessionId}`, { title });
|
|
254
254
|
await loadSessions();
|
|
@@ -272,123 +272,123 @@ export async function deleteSession(sessionId) {
|
|
|
272
272
|
await api('DELETE', `/api/sessions/${sessionId}`);
|
|
273
273
|
await loadSessions();
|
|
274
274
|
}
|
|
275
|
-
|
|
276
|
-
// Open the session's working directory in the user's configured editor
|
|
277
|
-
// (Settings → Editor, default `code`). Returns { editor, cwd } so the
|
|
278
|
-
// caller can surface which editor it launched.
|
|
279
|
-
export function openSessionInEditor(sessionId) {
|
|
280
|
-
return api('POST', `/api/sessions/${sessionId}/open-editor`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Per-session in-flight resume promise. Sidebar.onClick and the
|
|
284
|
-
// SessionsPage auto-resume effect can both fire for the same exited
|
|
285
|
-
// session in the same tick (clicking an exited row mounts SessionsPage
|
|
286
|
-
// which runs its effect AND awaits Sidebar's own POST). Without this
|
|
287
|
-
// dedup the backend gets two concurrent /resume requests and may spawn
|
|
288
|
-
// two PTYs against the same record. Cleared on resolve/reject.
|
|
289
|
-
const resumeInFlight = new Map(); // sessionId → Promise
|
|
290
|
-
// Sticky failure cache: once a resume fails, subsequent calls reject
|
|
291
|
-
// immediately with the cached error until clearResumeFailure(id) is
|
|
292
|
-
// called. Stops the SessionsPage auto-resume effect from looping on a
|
|
293
|
-
// session whose CLI keeps exiting (bad command, missing flag, etc.).
|
|
294
|
-
const resumeFailed = new Map(); // sessionId → Error
|
|
295
|
-
|
|
296
|
-
export function clearResumeFailure(sessionId) {
|
|
297
|
-
resumeFailed.delete(sessionId);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function resumeSession(sessionId) {
|
|
301
|
-
const failed = resumeFailed.get(sessionId);
|
|
302
|
-
if (failed) return Promise.reject(failed);
|
|
303
|
-
const cached = resumeInFlight.get(sessionId);
|
|
304
|
-
if (cached) return cached;
|
|
305
|
-
const p = (async () => {
|
|
306
|
-
const r = await api('POST', `/api/sessions/${sessionId}/resume`, {
|
|
307
|
-
// Resolved terminal theme → backend sets a matching COLORFGBG so the
|
|
308
|
-
// CLI's light/dark auto-detection follows the ccsm terminal.
|
|
309
|
-
theme: document.documentElement.dataset.theme,
|
|
310
|
-
// Seed the PTY at the pane's real size so alt-screen CLIs (claude)
|
|
311
|
-
// don't lay out at node-pty's 30-row default and get stranded short.
|
|
312
|
-
...(estimateTermSize() || {}),
|
|
313
|
-
});
|
|
314
|
-
await loadSessions();
|
|
315
|
-
return r.launched;
|
|
316
|
-
})();
|
|
317
|
-
resumeInFlight.set(sessionId, p);
|
|
318
|
-
p.then(
|
|
319
|
-
() => { resumeInFlight.delete(sessionId); },
|
|
320
|
-
(e) => { resumeInFlight.delete(sessionId); resumeFailed.set(sessionId, e); },
|
|
321
|
-
);
|
|
322
|
-
return p;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export async function loadWorkspaces() {
|
|
326
|
-
const r = await api('GET', '/api/workspaces');
|
|
327
|
-
S.workspaces.value = r.workspaces;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export async function deleteWorkspace(name) {
|
|
331
|
-
await api('DELETE', `/api/workspaces/${encodeURIComponent(name)}`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export async function refreshAll() {
|
|
335
|
-
await Promise.all([
|
|
336
|
-
loadSessions(),
|
|
337
|
-
loadFolders(),
|
|
338
|
-
loadWorkspaces(),
|
|
339
|
-
]);
|
|
340
|
-
S.lastRefreshAt.value = Date.now();
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// List existing CLI sessions discovered on disk for a given cli type.
|
|
344
|
-
// Paginated: page 0 returns all currently-active sessions + the first
|
|
345
|
-
// `limit` non-active (sorted mtime desc). Subsequent pages return the
|
|
346
|
-
// next slice of non-active sessions.
|
|
347
|
-
// Returns { sessions, totalActive, totalNonActive, total, offset, limit, hasMore }.
|
|
348
|
-
export async function listLocalCliSessions(cliType, { offset = 0, limit = 30 } = {}) {
|
|
349
|
-
const qs = `offset=${offset}&limit=${limit}`;
|
|
350
|
-
const r = await api('GET', `/api/cli-sessions/${cliType}?${qs}`);
|
|
351
|
-
return {
|
|
352
|
-
sessions: r.sessions || [],
|
|
353
|
-
totalActive: r.totalActive ?? 0,
|
|
354
|
-
totalNonActive: r.totalNonActive ?? 0,
|
|
355
|
-
total: r.total ?? (r.sessions?.length || 0),
|
|
356
|
-
offset: r.offset ?? offset,
|
|
357
|
-
limit: r.limit ?? limit,
|
|
358
|
-
hasMore: !!r.hasMore,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Adopt an existing upstream CLI session into ccsm. Returns the created
|
|
363
|
-
// (or existing) persistedSessions record.
|
|
364
|
-
export async function adoptSession({ cliId, cliSessionId, cwd, title, folderId }) {
|
|
365
|
-
const r = await api('POST', '/api/sessions/adopt', { cliId, cliSessionId, cwd, title, folderId });
|
|
366
|
-
return r;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export async function restartBackend() {
|
|
370
|
-
return api('POST', '/api/restart');
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
let consecutiveOffline = 0;
|
|
374
|
-
export async function pollHealth() {
|
|
375
|
-
const ctrl = new AbortController();
|
|
376
|
-
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
377
|
-
try {
|
|
378
|
-
const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
|
|
379
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
380
|
-
const j = await r.json();
|
|
381
|
-
consecutiveOffline = 0;
|
|
382
|
-
S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
|
|
383
|
-
if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
|
|
384
|
-
} catch (e) {
|
|
385
|
-
consecutiveOffline++;
|
|
386
|
-
S.serverHealth.value = {
|
|
387
|
-
state: 'offline',
|
|
388
|
-
error: String(e.message || e),
|
|
389
|
-
failureCount: consecutiveOffline,
|
|
390
|
-
};
|
|
391
|
-
} finally {
|
|
392
|
-
clearTimeout(t);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
275
|
+
|
|
276
|
+
// Open the session's working directory in the user's configured editor
|
|
277
|
+
// (Settings → Editor, default `code`). Returns { editor, cwd } so the
|
|
278
|
+
// caller can surface which editor it launched.
|
|
279
|
+
export function openSessionInEditor(sessionId) {
|
|
280
|
+
return api('POST', `/api/sessions/${sessionId}/open-editor`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Per-session in-flight resume promise. Sidebar.onClick and the
|
|
284
|
+
// SessionsPage auto-resume effect can both fire for the same exited
|
|
285
|
+
// session in the same tick (clicking an exited row mounts SessionsPage
|
|
286
|
+
// which runs its effect AND awaits Sidebar's own POST). Without this
|
|
287
|
+
// dedup the backend gets two concurrent /resume requests and may spawn
|
|
288
|
+
// two PTYs against the same record. Cleared on resolve/reject.
|
|
289
|
+
const resumeInFlight = new Map(); // sessionId → Promise
|
|
290
|
+
// Sticky failure cache: once a resume fails, subsequent calls reject
|
|
291
|
+
// immediately with the cached error until clearResumeFailure(id) is
|
|
292
|
+
// called. Stops the SessionsPage auto-resume effect from looping on a
|
|
293
|
+
// session whose CLI keeps exiting (bad command, missing flag, etc.).
|
|
294
|
+
const resumeFailed = new Map(); // sessionId → Error
|
|
295
|
+
|
|
296
|
+
export function clearResumeFailure(sessionId) {
|
|
297
|
+
resumeFailed.delete(sessionId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function resumeSession(sessionId) {
|
|
301
|
+
const failed = resumeFailed.get(sessionId);
|
|
302
|
+
if (failed) return Promise.reject(failed);
|
|
303
|
+
const cached = resumeInFlight.get(sessionId);
|
|
304
|
+
if (cached) return cached;
|
|
305
|
+
const p = (async () => {
|
|
306
|
+
const r = await api('POST', `/api/sessions/${sessionId}/resume`, {
|
|
307
|
+
// Resolved terminal theme → backend sets a matching COLORFGBG so the
|
|
308
|
+
// CLI's light/dark auto-detection follows the ccsm terminal.
|
|
309
|
+
theme: document.documentElement.dataset.theme,
|
|
310
|
+
// Seed the PTY at the pane's real size so alt-screen CLIs (claude)
|
|
311
|
+
// don't lay out at node-pty's 30-row default and get stranded short.
|
|
312
|
+
...(estimateTermSize() || {}),
|
|
313
|
+
});
|
|
314
|
+
await loadSessions();
|
|
315
|
+
return r.launched;
|
|
316
|
+
})();
|
|
317
|
+
resumeInFlight.set(sessionId, p);
|
|
318
|
+
p.then(
|
|
319
|
+
() => { resumeInFlight.delete(sessionId); },
|
|
320
|
+
(e) => { resumeInFlight.delete(sessionId); resumeFailed.set(sessionId, e); },
|
|
321
|
+
);
|
|
322
|
+
return p;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function loadWorkspaces() {
|
|
326
|
+
const r = await api('GET', '/api/workspaces');
|
|
327
|
+
S.workspaces.value = r.workspaces;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function deleteWorkspace(name) {
|
|
331
|
+
await api('DELETE', `/api/workspaces/${encodeURIComponent(name)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function refreshAll() {
|
|
335
|
+
await Promise.all([
|
|
336
|
+
loadSessions(),
|
|
337
|
+
loadFolders(),
|
|
338
|
+
loadWorkspaces(),
|
|
339
|
+
]);
|
|
340
|
+
S.lastRefreshAt.value = Date.now();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// List existing CLI sessions discovered on disk for a given cli type.
|
|
344
|
+
// Paginated: page 0 returns all currently-active sessions + the first
|
|
345
|
+
// `limit` non-active (sorted mtime desc). Subsequent pages return the
|
|
346
|
+
// next slice of non-active sessions.
|
|
347
|
+
// Returns { sessions, totalActive, totalNonActive, total, offset, limit, hasMore }.
|
|
348
|
+
export async function listLocalCliSessions(cliType, { offset = 0, limit = 30 } = {}) {
|
|
349
|
+
const qs = `offset=${offset}&limit=${limit}`;
|
|
350
|
+
const r = await api('GET', `/api/cli-sessions/${cliType}?${qs}`);
|
|
351
|
+
return {
|
|
352
|
+
sessions: r.sessions || [],
|
|
353
|
+
totalActive: r.totalActive ?? 0,
|
|
354
|
+
totalNonActive: r.totalNonActive ?? 0,
|
|
355
|
+
total: r.total ?? (r.sessions?.length || 0),
|
|
356
|
+
offset: r.offset ?? offset,
|
|
357
|
+
limit: r.limit ?? limit,
|
|
358
|
+
hasMore: !!r.hasMore,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Adopt an existing upstream CLI session into ccsm. Returns the created
|
|
363
|
+
// (or existing) persistedSessions record.
|
|
364
|
+
export async function adoptSession({ cliId, cliSessionId, cwd, title, folderId }) {
|
|
365
|
+
const r = await api('POST', '/api/sessions/adopt', { cliId, cliSessionId, cwd, title, folderId });
|
|
366
|
+
return r;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function restartBackend() {
|
|
370
|
+
return api('POST', '/api/restart');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let consecutiveOffline = 0;
|
|
374
|
+
export async function pollHealth() {
|
|
375
|
+
const ctrl = new AbortController();
|
|
376
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
377
|
+
try {
|
|
378
|
+
const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
|
|
379
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
380
|
+
const j = await r.json();
|
|
381
|
+
consecutiveOffline = 0;
|
|
382
|
+
S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
|
|
383
|
+
if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
|
|
384
|
+
} catch (e) {
|
|
385
|
+
consecutiveOffline++;
|
|
386
|
+
S.serverHealth.value = {
|
|
387
|
+
state: 'offline',
|
|
388
|
+
error: String(e.message || e),
|
|
389
|
+
failureCount: consecutiveOffline,
|
|
390
|
+
};
|
|
391
|
+
} finally {
|
|
392
|
+
clearTimeout(t);
|
|
393
|
+
}
|
|
394
|
+
}
|