@bakapiano/ccsm 0.22.6 → 0.22.8
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 +521 -540
- package/README.md +186 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +36 -139
- package/lib/codexSeed.js +126 -183
- package/lib/config.js +277 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/persistedSessions.js +179 -139
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/winPath.js +1 -1
- 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 +154 -154
- 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 +546 -546
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2347 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +349 -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 +28 -0
- 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 +730 -713
- package/public/js/pages/LaunchPage.js +403 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +54 -54
- package/public/js/state.js +335 -335
- package/public/js/util.js +1 -1
- 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 +1748 -1817
- package/lib/localCliSessions.js +0 -519
- package/public/js/components/AdoptModal.js +0 -261
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
|
@@ -1,713 +1,730 @@
|
|
|
1
|
-
// Settings page · summary lists of CLIs / Repos / Folders + General
|
|
2
|
-
// (port / work dir / theme). Each row has Edit + Delete; "+ Add"
|
|
3
|
-
// opens the same modal form used inline-from-launch.
|
|
4
|
-
|
|
5
|
-
import { html } from '../html.js';
|
|
6
|
-
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
-
import {
|
|
8
|
-
config, configDirty, accentColor, folders, workspaces, serverHealth,
|
|
9
|
-
restartInFlight, themeMode,
|
|
10
|
-
setAccentColor, ACCENT_DEFAULT, setThemeMode,
|
|
11
|
-
} from '../state.js';
|
|
12
|
-
import {
|
|
13
|
-
api, loadConfig, loadWorkspaces, loadFolders,
|
|
14
|
-
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
15
|
-
createRepo, updateRepo, deleteRepo,
|
|
16
|
-
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
17
|
-
deleteWorkspace, restartBackend,
|
|
18
|
-
} from '../api.js';
|
|
19
|
-
import { setToast } from '../toast.js';
|
|
20
|
-
import { ccsmConfirm } from '../dialog.js';
|
|
21
|
-
import { keybindings, setBinding, resetBinding, ACTIONS, formatCombo } from '../keybindings.js';
|
|
22
|
-
import { KeybindingRecorder } from '../components/KeybindingRecorder.js';
|
|
23
|
-
import { Card } from '../components/Card.js';
|
|
24
|
-
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
25
|
-
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
26
|
-
import { useDragSort } from '../components/useDragSort.js';
|
|
27
|
-
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSun, IconMoon, IconMonitor } from '../icons.js';
|
|
28
|
-
import { parseArgs, formatArgs } from '../util.js';
|
|
29
|
-
|
|
30
|
-
// Tokenize the
|
|
31
|
-
// the backend. Form values arrive as strings (text inputs) — backend
|
|
32
|
-
// stores arrays. parseArgs handles shell-style quoting so users can type
|
|
33
|
-
// `-Model "claude-opus-4-8"` or `-Path 'C:\some dir\bin'` and get sane
|
|
34
|
-
// argv splitting instead of a literal-quote token.
|
|
35
|
-
function tokenizeCliArgs(v) {
|
|
36
|
-
const tok = (x) => typeof x === 'string' ? parseArgs(x) : x;
|
|
37
|
-
return {
|
|
38
|
-
...v,
|
|
39
|
-
args: tok(v.args),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Type → smart defaults. Choosing a type in the form auto-fills
|
|
46
|
-
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
47
|
-
const CLI_TYPE_DEFAULTS = {
|
|
48
|
-
claude: { command: 'claude',
|
|
49
|
-
codex: { command: 'codex',
|
|
50
|
-
copilot: { command: 'copilot',
|
|
51
|
-
other: {
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function cliFieldsFor({ creating } = {}) {
|
|
55
|
-
return [
|
|
56
|
-
{ key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
|
|
57
|
-
{ value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
|
|
58
|
-
{ value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
|
|
59
|
-
{ value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
|
|
60
|
-
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
61
|
-
],
|
|
62
|
-
// Type-change side effects. For known types we force the
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// there's no value in leaving stale strings around. For
|
|
66
|
-
// type='other' we leave existing args alone so the user can
|
|
67
|
-
// keep editing them. Name + command are only prefilled when
|
|
68
|
-
// creating (don't clobber a saved CLI's name on edit).
|
|
69
|
-
onChange: (v, next) => {
|
|
70
|
-
const d = CLI_TYPE_DEFAULTS[v];
|
|
71
|
-
if (!d) return null;
|
|
72
|
-
const patch = {};
|
|
73
|
-
if (v !== 'other') {
|
|
74
|
-
patch.
|
|
75
|
-
patch.
|
|
76
|
-
}
|
|
77
|
-
if (creating) {
|
|
78
|
-
if (!next.command || !next.command.trim()) patch.command = d.command || '';
|
|
79
|
-
if (!next.name || !next.name.trim()) {
|
|
80
|
-
patch.name = v === 'claude' ? 'Claude Code'
|
|
81
|
-
: v === 'codex' ? 'OpenAI Codex'
|
|
82
|
-
: v === 'copilot' ? 'GitHub Copilot'
|
|
83
|
-
: '';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return patch;
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
90
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
91
|
-
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
|
-
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
93
|
-
{ key: '
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
}, [cfg]);
|
|
160
|
-
|
|
161
|
-
if (!cfg || !general) return null;
|
|
162
|
-
|
|
163
|
-
const saveGeneral = async (patch) => {
|
|
164
|
-
const merged = { ...general, ...patch };
|
|
165
|
-
setGeneral(merged);
|
|
166
|
-
try {
|
|
167
|
-
const saved = await api('PUT', '/api/config', {
|
|
168
|
-
...cfg,
|
|
169
|
-
workDir: (merged.workDir || '').trim(),
|
|
170
|
-
editor: (merged.editor || '').trim(),
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
if (
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
onClose=${close}
|
|
336
|
-
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
337
|
-
onSubmit=${async (v) => {
|
|
338
|
-
try {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
<
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1
|
+
// Settings page · summary lists of CLIs / Repos / Folders + General
|
|
2
|
+
// (port / work dir / theme). Each row has Edit + Delete; "+ Add"
|
|
3
|
+
// opens the same modal form used inline-from-launch.
|
|
4
|
+
|
|
5
|
+
import { html } from '../html.js';
|
|
6
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
+
import {
|
|
8
|
+
config, configDirty, accentColor, folders, workspaces, serverHealth,
|
|
9
|
+
restartInFlight, themeMode,
|
|
10
|
+
setAccentColor, ACCENT_DEFAULT, setThemeMode,
|
|
11
|
+
} from '../state.js';
|
|
12
|
+
import {
|
|
13
|
+
api, loadConfig, loadWorkspaces, loadFolders,
|
|
14
|
+
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
15
|
+
createRepo, updateRepo, deleteRepo,
|
|
16
|
+
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
17
|
+
deleteWorkspace, restartBackend,
|
|
18
|
+
} from '../api.js';
|
|
19
|
+
import { setToast } from '../toast.js';
|
|
20
|
+
import { ccsmConfirm } from '../dialog.js';
|
|
21
|
+
import { keybindings, setBinding, resetBinding, ACTIONS, formatCombo } from '../keybindings.js';
|
|
22
|
+
import { KeybindingRecorder } from '../components/KeybindingRecorder.js';
|
|
23
|
+
import { Card } from '../components/Card.js';
|
|
24
|
+
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
25
|
+
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
26
|
+
import { useDragSort } from '../components/useDragSort.js';
|
|
27
|
+
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSun, IconMoon, IconMonitor } from '../icons.js';
|
|
28
|
+
import { parseArgs, formatArgs } from '../util.js';
|
|
29
|
+
|
|
30
|
+
// Tokenize the free-form args fields into string[] before they hit
|
|
31
|
+
// the backend. Form values arrive as strings (text inputs) — backend
|
|
32
|
+
// stores arrays. parseArgs handles shell-style quoting so users can type
|
|
33
|
+
// `-Model "claude-opus-4-8"` or `-Path 'C:\some dir\bin'` and get sane
|
|
34
|
+
// argv splitting instead of a literal-quote token.
|
|
35
|
+
function tokenizeCliArgs(v) {
|
|
36
|
+
const tok = (x) => typeof x === 'string' ? parseArgs(x) : x;
|
|
37
|
+
return {
|
|
38
|
+
...v,
|
|
39
|
+
args: tok(v.args),
|
|
40
|
+
resumeLatestArgs: tok(v.resumeLatestArgs),
|
|
41
|
+
resumePickerArgs: tok(v.resumePickerArgs),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Type → smart defaults. Choosing a type in the form auto-fills resume args
|
|
46
|
+
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
47
|
+
const CLI_TYPE_DEFAULTS = {
|
|
48
|
+
claude: { command: 'claude', resumeLatestArgs: '--continue', resumePickerArgs: '--resume' },
|
|
49
|
+
codex: { command: 'codex', resumeLatestArgs: 'resume --last', resumePickerArgs: 'resume' },
|
|
50
|
+
copilot: { command: 'copilot', resumeLatestArgs: '--continue', resumePickerArgs: '--resume' },
|
|
51
|
+
other: { resumeLatestArgs: '', resumePickerArgs: '' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function cliFieldsFor({ creating } = {}) {
|
|
55
|
+
return [
|
|
56
|
+
{ key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
|
|
57
|
+
{ value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
|
|
58
|
+
{ value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
|
|
59
|
+
{ value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
|
|
60
|
+
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
61
|
+
],
|
|
62
|
+
// Type-change side effects. For known types we force the
|
|
63
|
+
// folder-level resume args to the canonical template — those
|
|
64
|
+
// fields are locked anyway so
|
|
65
|
+
// there's no value in leaving stale strings around. For
|
|
66
|
+
// type='other' we leave existing args alone so the user can
|
|
67
|
+
// keep editing them. Name + command are only prefilled when
|
|
68
|
+
// creating (don't clobber a saved CLI's name on edit).
|
|
69
|
+
onChange: (v, next) => {
|
|
70
|
+
const d = CLI_TYPE_DEFAULTS[v];
|
|
71
|
+
if (!d) return null;
|
|
72
|
+
const patch = {};
|
|
73
|
+
if (v !== 'other') {
|
|
74
|
+
patch.resumeLatestArgs = d.resumeLatestArgs;
|
|
75
|
+
patch.resumePickerArgs = d.resumePickerArgs;
|
|
76
|
+
}
|
|
77
|
+
if (creating) {
|
|
78
|
+
if (!next.command || !next.command.trim()) patch.command = d.command || '';
|
|
79
|
+
if (!next.name || !next.name.trim()) {
|
|
80
|
+
patch.name = v === 'claude' ? 'Claude Code'
|
|
81
|
+
: v === 'codex' ? 'OpenAI Codex'
|
|
82
|
+
: v === 'copilot' ? 'GitHub Copilot'
|
|
83
|
+
: '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return patch;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
90
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
91
|
+
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
|
+
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
93
|
+
{ key: 'resumeLatestArgs', label: 'Resume latest args', mono: true, placeholder: '--continue',
|
|
94
|
+
readOnly: (d) => d.type && d.type !== 'other',
|
|
95
|
+
hint: (d) => d.type && d.type !== 'other'
|
|
96
|
+
? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
|
|
97
|
+
: 'Used when Resume behavior is set to latest.' },
|
|
98
|
+
{ key: 'resumePickerArgs', label: 'Resume picker args', mono: true, placeholder: '--resume',
|
|
99
|
+
readOnly: (d) => d.type && d.type !== 'other',
|
|
100
|
+
hint: (d) => d.type && d.type !== 'other'
|
|
101
|
+
? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
|
|
102
|
+
: 'Used when Resume behavior is set to picker.' },
|
|
103
|
+
{ key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
|
|
104
|
+
{ value: 'direct', label: 'direct (real .exe / .cmd)' },
|
|
105
|
+
{ value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
|
|
106
|
+
{ value: 'cmd', label: 'cmd (doskey)' },
|
|
107
|
+
] },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Section({ title, meta, children }) {
|
|
112
|
+
return html`
|
|
113
|
+
<section class="settings-section">
|
|
114
|
+
<header class="settings-section-head">
|
|
115
|
+
<h2 class="settings-section-title">${title}</h2>
|
|
116
|
+
${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
|
|
117
|
+
</header>
|
|
118
|
+
<div class="settings-section-body">${children}</div>
|
|
119
|
+
</section>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Field definitions shared with Launch picker ──────────────────────
|
|
123
|
+
// (CLI fields built lazily via cliFieldsFor — see above.)
|
|
124
|
+
|
|
125
|
+
const repoFields = [
|
|
126
|
+
{ key: 'name', label: 'Name', placeholder: 'my-repo', autoFocus: true, required: true },
|
|
127
|
+
{ key: 'url', label: 'URL', mono: true, placeholder: 'https://github.com/me/foo.git', required: true },
|
|
128
|
+
{ key: 'defaultSelected', label: 'Pre-select on launch', type: 'checkbox',
|
|
129
|
+
hint: 'Auto-checked in the Repos picker for new sessions' },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const folderFields = [
|
|
133
|
+
{ key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// ── Page ─────────────────────────────────────────────────────────────
|
|
137
|
+
export function ConfigurePage() {
|
|
138
|
+
const cfg = config.value;
|
|
139
|
+
const [edit, setEdit] = useState(null); // { kind, payload? }
|
|
140
|
+
const [general, setGeneral] = useState(null);
|
|
141
|
+
const [savedAt, setSavedAt] = useState('');
|
|
142
|
+
|
|
143
|
+
const folderDnd = useDragSort(
|
|
144
|
+
folders.value.map((f) => f.id),
|
|
145
|
+
async (nextIds) => {
|
|
146
|
+
try { await reorderFolders(nextIds); }
|
|
147
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (cfg && !general) {
|
|
153
|
+
setGeneral({
|
|
154
|
+
workDir: cfg.workDir,
|
|
155
|
+
editor: cfg.editor,
|
|
156
|
+
resumeMode: cfg.resumeMode === 'picker' ? 'picker' : 'latest',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}, [cfg]);
|
|
160
|
+
|
|
161
|
+
if (!cfg || !general) return null;
|
|
162
|
+
|
|
163
|
+
const saveGeneral = async (patch) => {
|
|
164
|
+
const merged = { ...general, ...patch };
|
|
165
|
+
setGeneral(merged);
|
|
166
|
+
try {
|
|
167
|
+
const saved = await api('PUT', '/api/config', {
|
|
168
|
+
...cfg,
|
|
169
|
+
workDir: (merged.workDir || '').trim(),
|
|
170
|
+
editor: (merged.editor || '').trim(),
|
|
171
|
+
resumeMode: merged.resumeMode === 'picker' ? 'picker' : 'latest',
|
|
172
|
+
});
|
|
173
|
+
config.value = saved;
|
|
174
|
+
setToast('saved');
|
|
175
|
+
await loadWorkspaces();
|
|
176
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const close = () => setEdit(null);
|
|
180
|
+
|
|
181
|
+
return html`
|
|
182
|
+
<${PageTitleBar} title="Settings" />
|
|
183
|
+
<div class="settings-scroll">
|
|
184
|
+
|
|
185
|
+
<${Section} title="General">
|
|
186
|
+
<div class="config-grid">
|
|
187
|
+
<div class="field">
|
|
188
|
+
<span class="label">Appearance</span>
|
|
189
|
+
<${ThemeToggle} />
|
|
190
|
+
</div>
|
|
191
|
+
<div class="field">
|
|
192
|
+
<span class="label">Theme accent</span>
|
|
193
|
+
<${AccentPicker} />
|
|
194
|
+
</div>
|
|
195
|
+
<div class="field">
|
|
196
|
+
<span class="label">Version</span>
|
|
197
|
+
<${VersionField} />
|
|
198
|
+
</div>
|
|
199
|
+
<div class="field">
|
|
200
|
+
<span class="label">Backend</span>
|
|
201
|
+
<${RestartButton} />
|
|
202
|
+
</div>
|
|
203
|
+
<div class="field">
|
|
204
|
+
<span class="label">Resume behavior</span>
|
|
205
|
+
<div class="seg" role="group" aria-label="Resume behavior">
|
|
206
|
+
${[
|
|
207
|
+
{ id: 'latest', label: 'Resume latest' },
|
|
208
|
+
{ id: 'picker', label: 'Resume picker' },
|
|
209
|
+
].map((o) => html`
|
|
210
|
+
<button key=${o.id} type="button"
|
|
211
|
+
class=${`seg-btn${general.resumeMode === o.id ? ' is-active' : ''}`}
|
|
212
|
+
aria-pressed=${general.resumeMode === o.id}
|
|
213
|
+
onClick=${() => saveGeneral({ resumeMode: o.id })}>
|
|
214
|
+
<span>${o.label}</span>
|
|
215
|
+
</button>`)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<label class="field">
|
|
219
|
+
<span class="label">Editor</span>
|
|
220
|
+
<input type="text" class="mono" value=${general.editor || ''}
|
|
221
|
+
placeholder="code"
|
|
222
|
+
onChange=${(e) => saveGeneral({ editor: e.target.value })} />
|
|
223
|
+
<span class="hint">Command for a session's “Open in editor” action. Default <code>code</code> (VS Code). Try <code>cursor</code>, <code>code-insiders</code>, …</span>
|
|
224
|
+
</label>
|
|
225
|
+
</div>
|
|
226
|
+
</${Section}>
|
|
227
|
+
|
|
228
|
+
<${Section} title="CLIs" meta=${html`Built-in entries (<code>claude</code>, <code>codex</code>, <code>copilot</code>) auto-probe your PATH.`}>
|
|
229
|
+
<${EntityList}
|
|
230
|
+
kind="cli"
|
|
231
|
+
addLabel="Add CLI"
|
|
232
|
+
items=${(cfg.clis || []).map((c) => {
|
|
233
|
+
const tags = [];
|
|
234
|
+
if (cfg.defaultCliId === c.id) tags.push({ label: 'default', tone: 'accent' });
|
|
235
|
+
if (c.builtin) tags.push({ label: c.installed ? 'installed' : 'not found', tone: c.installed ? 'ok' : 'warn' });
|
|
236
|
+
const Icon = IconForCliType(c.type);
|
|
237
|
+
return {
|
|
238
|
+
id: c.id,
|
|
239
|
+
icon: html`<${Icon} />`,
|
|
240
|
+
primary: c.name,
|
|
241
|
+
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + formatArgs(c.args) : ''}</span>${c.shell && c.shell !== 'direct' ? html` · ${c.shell}` : null}`,
|
|
242
|
+
badges: tags,
|
|
243
|
+
undeletable: c.builtin,
|
|
244
|
+
raw: c,
|
|
245
|
+
};
|
|
246
|
+
})}
|
|
247
|
+
onAdd=${() => setEdit({ kind: 'cli-new' })}
|
|
248
|
+
onEdit=${(it) => setEdit({ kind: 'cli-edit', payload: it.raw })}
|
|
249
|
+
onDelete=${async (it) => {
|
|
250
|
+
if (it.undeletable) return setToast(`"${it.primary}" is built-in and can't be deleted`, 'error');
|
|
251
|
+
if (cfg.clis.length === 1) return setToast('cannot delete the last CLI', 'error');
|
|
252
|
+
const ok = await ccsmConfirm(`Delete CLI "${it.primary}"?`, { okLabel: 'Delete', danger: true });
|
|
253
|
+
if (!ok) return;
|
|
254
|
+
try { await deleteCli(it.id); setToast('deleted'); }
|
|
255
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
256
|
+
}}
|
|
257
|
+
onActivate=${async (it) => {
|
|
258
|
+
if (cfg.defaultCliId === it.id) return;
|
|
259
|
+
try { await setDefaultCli(it.id); setToast(`default · ${it.primary}`); }
|
|
260
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
261
|
+
}}
|
|
262
|
+
emptyHint="No CLIs configured."
|
|
263
|
+
/>
|
|
264
|
+
</${Section}>
|
|
265
|
+
|
|
266
|
+
<${Section} title="Repositories" meta="Available for clone-on-launch into a new workspace.">
|
|
267
|
+
<${EntityList}
|
|
268
|
+
kind="repo"
|
|
269
|
+
addLabel="Add Repo"
|
|
270
|
+
items=${(cfg.repos || []).map((r) => ({
|
|
271
|
+
id: r.name,
|
|
272
|
+
icon: html`<${IconBranch} />`,
|
|
273
|
+
primary: r.name,
|
|
274
|
+
secondary: html`<span class="mono">${r.url}</span>`,
|
|
275
|
+
badge: r.defaultSelected ? 'auto' : null,
|
|
276
|
+
raw: r,
|
|
277
|
+
}))}
|
|
278
|
+
onAdd=${() => setEdit({ kind: 'repo-new' })}
|
|
279
|
+
onEdit=${(it) => setEdit({ kind: 'repo-edit', payload: it.raw })}
|
|
280
|
+
onDelete=${async (it) => {
|
|
281
|
+
const ok = await ccsmConfirm(`Remove repo "${it.primary}" from the list?`, { okLabel: 'Remove', danger: true });
|
|
282
|
+
if (!ok) return;
|
|
283
|
+
try { await deleteRepo(it.id); setToast('removed'); }
|
|
284
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
285
|
+
}}
|
|
286
|
+
emptyHint="No repos configured."
|
|
287
|
+
/>
|
|
288
|
+
</${Section}>
|
|
289
|
+
|
|
290
|
+
<${Section} title="Folders" meta="Buckets that group sessions in the sidebar.">
|
|
291
|
+
<${EntityList}
|
|
292
|
+
kind="folder"
|
|
293
|
+
addLabel="Add Folder"
|
|
294
|
+
dnd=${folderDnd}
|
|
295
|
+
items=${folders.value.map((f) => ({
|
|
296
|
+
id: f.id,
|
|
297
|
+
icon: html`<${IconFolder} />`,
|
|
298
|
+
primary: f.name,
|
|
299
|
+
secondary: null,
|
|
300
|
+
raw: f,
|
|
301
|
+
}))}
|
|
302
|
+
onAdd=${() => setEdit({ kind: 'folder-new' })}
|
|
303
|
+
onEdit=${(it) => setEdit({ kind: 'folder-edit', payload: it.raw })}
|
|
304
|
+
onDelete=${async (it) => {
|
|
305
|
+
const ok = await ccsmConfirm(`Delete folder "${it.primary}"? Sessions inside move to Unsorted.`, { okLabel: 'Delete', danger: true });
|
|
306
|
+
if (!ok) return;
|
|
307
|
+
try { await deleteFolder(it.id); setToast('deleted'); }
|
|
308
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
309
|
+
}}
|
|
310
|
+
emptyHint="No folders yet."
|
|
311
|
+
/>
|
|
312
|
+
</${Section}>
|
|
313
|
+
|
|
314
|
+
<${Section} title="Workspaces"
|
|
315
|
+
meta=${html`Auto-allocated <code>ws-N</code> folders under the work directory. Each holds one or more repo clones.`}>
|
|
316
|
+
<div class="config-grid">
|
|
317
|
+
<label class="field">
|
|
318
|
+
<span class="label">Work directory</span>
|
|
319
|
+
<input type="text" value=${general.workDir}
|
|
320
|
+
onChange=${(e) => saveGeneral({ workDir: e.target.value })} />
|
|
321
|
+
</label>
|
|
322
|
+
</div>
|
|
323
|
+
<${WorkspaceList} />
|
|
324
|
+
</${Section}>
|
|
325
|
+
|
|
326
|
+
<${Section} title="Keyboard shortcuts"
|
|
327
|
+
meta="Click a binding to record a new combo. Press Esc to cancel.">
|
|
328
|
+
<${KeybindingsList} />
|
|
329
|
+
</${Section}>
|
|
330
|
+
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
${edit?.kind === 'cli-new' ? html`
|
|
334
|
+
<${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
|
|
335
|
+
onClose=${close} submitLabel="Create"
|
|
336
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
337
|
+
onSubmit=${async (v) => {
|
|
338
|
+
try { await createCli(tokenizeCliArgs(v)); setToast(`created CLI · ${v.name}`); }
|
|
339
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
340
|
+
}} />` : null}
|
|
341
|
+
|
|
342
|
+
${edit?.kind === 'cli-edit' ? html`
|
|
343
|
+
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
|
|
344
|
+
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
345
|
+
initial=${{
|
|
346
|
+
...edit.payload,
|
|
347
|
+
args: formatArgs(edit.payload.args),
|
|
348
|
+
resumeLatestArgs: formatArgs(edit.payload.resumeLatestArgs),
|
|
349
|
+
resumePickerArgs: formatArgs(edit.payload.resumePickerArgs),
|
|
350
|
+
}}
|
|
351
|
+
onClose=${close}
|
|
352
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
353
|
+
onSubmit=${async (v) => {
|
|
354
|
+
try {
|
|
355
|
+
await updateCli(edit.payload.id, tokenizeCliArgs(v));
|
|
356
|
+
setToast('saved');
|
|
357
|
+
} catch (e) { setToast(e.message, 'error'); throw e; }
|
|
358
|
+
}} />` : null}
|
|
359
|
+
|
|
360
|
+
${edit?.kind === 'repo-new' ? html`
|
|
361
|
+
<${EntityFormModal} title="New repo" fields=${repoFields}
|
|
362
|
+
onClose=${close} submitLabel="Add"
|
|
363
|
+
onSubmit=${async (v) => {
|
|
364
|
+
try { await createRepo(v); setToast(`added repo · ${v.name}`); }
|
|
365
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
366
|
+
}} />` : null}
|
|
367
|
+
|
|
368
|
+
${edit?.kind === 'repo-edit' ? html`
|
|
369
|
+
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${repoFields}
|
|
370
|
+
initial=${edit.payload}
|
|
371
|
+
onClose=${close}
|
|
372
|
+
onSubmit=${async (v) => {
|
|
373
|
+
try { await updateRepo(edit.payload.name, v); setToast('saved'); }
|
|
374
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
375
|
+
}} />` : null}
|
|
376
|
+
|
|
377
|
+
${edit?.kind === 'folder-new' ? html`
|
|
378
|
+
<${EntityFormModal} title="New folder" fields=${folderFields}
|
|
379
|
+
onClose=${close} submitLabel="Create"
|
|
380
|
+
onSubmit=${async (v) => {
|
|
381
|
+
try { await createFolder(v.name); await loadFolders(); setToast(`created folder · ${v.name}`); }
|
|
382
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
383
|
+
}} />` : null}
|
|
384
|
+
|
|
385
|
+
${edit?.kind === 'folder-edit' ? html`
|
|
386
|
+
<${EntityFormModal} title=${`Rename ${edit.payload.name}`} fields=${folderFields}
|
|
387
|
+
initial=${edit.payload}
|
|
388
|
+
onClose=${close}
|
|
389
|
+
onSubmit=${async (v) => {
|
|
390
|
+
try { await renameFolder(edit.payload.id, v.name.trim()); await loadFolders(); setToast('renamed'); }
|
|
391
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
392
|
+
}} />` : null}
|
|
393
|
+
`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Generic "list of rows + Add button" used by all three sections.
|
|
397
|
+
function EntityList({ items, onAdd, onEdit, onDelete, onActivate, emptyHint, dnd, addLabel = 'Add' }) {
|
|
398
|
+
return html`
|
|
399
|
+
<div class="entity-list">
|
|
400
|
+
${items.length === 0
|
|
401
|
+
? html`<div class="entity-empty">${emptyHint}</div>`
|
|
402
|
+
: items.map((it) => {
|
|
403
|
+
const rowProps = dnd ? dnd.rowProps(it.id) : {};
|
|
404
|
+
const handleProps = dnd ? dnd.handleProps(it.id) : {};
|
|
405
|
+
const badges = it.badges || (it.badge ? [{ label: it.badge, tone: 'accent' }] : []);
|
|
406
|
+
return html`
|
|
407
|
+
<div class=${`entity-row${dnd ? ' is-draggable' : ''}`} key=${it.id}
|
|
408
|
+
...${rowProps} ...${handleProps}>
|
|
409
|
+
${dnd ? html`<span class="entity-row-grip" aria-hidden="true">⋮⋮</span>` : null}
|
|
410
|
+
<span class="entity-row-icon">${it.icon}</span>
|
|
411
|
+
<span class="entity-row-main">
|
|
412
|
+
<span class="entity-row-primary">
|
|
413
|
+
${it.primary}
|
|
414
|
+
${badges.map((b) => html`
|
|
415
|
+
<span class=${`entity-row-badge tone-${b.tone || 'accent'}`}>${b.label}</span>`)}
|
|
416
|
+
</span>
|
|
417
|
+
${it.secondary ? html`<span class="entity-row-secondary">${it.secondary}</span>` : null}
|
|
418
|
+
</span>
|
|
419
|
+
<span class="entity-row-actions">
|
|
420
|
+
${onActivate ? html`
|
|
421
|
+
<button class="entity-row-action" title="Set default"
|
|
422
|
+
onClick=${() => onActivate(it)}>★</button>` : null}
|
|
423
|
+
<button class="entity-row-action" title="Edit"
|
|
424
|
+
onClick=${() => onEdit(it)}><${IconPencil} /></button>
|
|
425
|
+
${it.undeletable ? null : html`
|
|
426
|
+
<button class="entity-row-action danger" title="Delete"
|
|
427
|
+
onClick=${() => onDelete(it)}><${IconClose} /></button>`}
|
|
428
|
+
</span>
|
|
429
|
+
</div>`;
|
|
430
|
+
})}
|
|
431
|
+
<button class="entity-add" type="button" onClick=${onAdd}>
|
|
432
|
+
<span>${addLabel}</span>
|
|
433
|
+
</button>
|
|
434
|
+
</div>`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Workspace list ───────────────────────────────────────────────────
|
|
438
|
+
function WorkspaceList() {
|
|
439
|
+
const ws = workspaces.value || [];
|
|
440
|
+
const inUseBy = 'session';
|
|
441
|
+
if (ws.length === 0) {
|
|
442
|
+
return html`<div class="entity-empty">No workspaces yet — they're created automatically on launch.</div>`;
|
|
443
|
+
}
|
|
444
|
+
const onDelete = async (w) => {
|
|
445
|
+
if (w.inUse) return setToast(`"${w.name}" is in use by a ${inUseBy}`, 'error');
|
|
446
|
+
const ok = await ccsmConfirm(
|
|
447
|
+
`Delete workspace "${w.name}"? This removes the directory and all repo clones inside.`,
|
|
448
|
+
{ okLabel: 'Delete', danger: true },
|
|
449
|
+
);
|
|
450
|
+
if (!ok) return;
|
|
451
|
+
try {
|
|
452
|
+
await deleteWorkspace(w.name);
|
|
453
|
+
await loadWorkspaces();
|
|
454
|
+
setToast(`deleted · ${w.name}`);
|
|
455
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
456
|
+
};
|
|
457
|
+
return html`
|
|
458
|
+
<div class="entity-list">
|
|
459
|
+
${ws.map((w) => {
|
|
460
|
+
const repoCount = (w.repos || []).filter((r) => r.exists).length;
|
|
461
|
+
return html`
|
|
462
|
+
<div class="entity-row" key=${w.path}>
|
|
463
|
+
<span class="entity-row-icon"><${IconFolder} /></span>
|
|
464
|
+
<span class="entity-row-main">
|
|
465
|
+
<span class="entity-row-primary">
|
|
466
|
+
${w.name}
|
|
467
|
+
${w.inUse ? html`<span class="entity-row-badge tone-warn">in use</span>` : null}
|
|
468
|
+
</span>
|
|
469
|
+
<span class="entity-row-secondary">
|
|
470
|
+
<span class="mono">${w.path}</span>
|
|
471
|
+
${repoCount > 0 ? html` · ${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}` : null}
|
|
472
|
+
</span>
|
|
473
|
+
</span>
|
|
474
|
+
<span class="entity-row-actions">
|
|
475
|
+
<button class=${`entity-row-action danger${w.inUse ? ' is-disabled' : ''}`}
|
|
476
|
+
title=${w.inUse ? `In use by a ${inUseBy}` : 'Delete'}
|
|
477
|
+
disabled=${w.inUse}
|
|
478
|
+
onClick=${() => onDelete(w)}><${IconClose} /></button>
|
|
479
|
+
</span>
|
|
480
|
+
</div>`;
|
|
481
|
+
})}
|
|
482
|
+
</div>`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Accent picker (unchanged) ────────────────────────────────────────
|
|
486
|
+
const PRESETS = [
|
|
487
|
+
{ name: 'Ocean', hex: '#2f6fa3' },
|
|
488
|
+
{ name: 'Claude copper', hex: '#b3614a' },
|
|
489
|
+
{ name: 'Anthropic ink', hex: '#1a1815' },
|
|
490
|
+
{ name: 'Forest', hex: '#3f7a4a' },
|
|
491
|
+
{ name: 'Amber', hex: '#c4892b' },
|
|
492
|
+
{ name: 'Berry', hex: '#a44b78' },
|
|
493
|
+
{ name: 'Slate', hex: '#4a5563' },
|
|
494
|
+
{ name: 'Crimson', hex: '#b73f3f' },
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
function VersionField() {
|
|
498
|
+
const [info, setInfo] = useState(null);
|
|
499
|
+
const [checking, setChecking] = useState(true);
|
|
500
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
501
|
+
|
|
502
|
+
const refresh = async (force = false) => {
|
|
503
|
+
setChecking(true);
|
|
504
|
+
try {
|
|
505
|
+
const r = await api('GET', '/api/version' + (force ? '?refresh=1' : ''));
|
|
506
|
+
setInfo(r);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
setInfo({ error: e.message });
|
|
509
|
+
} finally {
|
|
510
|
+
setChecking(false);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
useEffect(() => { refresh(false); }, []);
|
|
514
|
+
|
|
515
|
+
const onUpgrade = async () => {
|
|
516
|
+
if (!info?.updateAvailable) return;
|
|
517
|
+
setUpgrading(true);
|
|
518
|
+
try {
|
|
519
|
+
const r = await api('POST', '/api/upgrade', { target: 'latest' });
|
|
520
|
+
setToast(`upgrading to v${info.latest} · backend will restart`);
|
|
521
|
+
if (r?.helperUrl) {
|
|
522
|
+
setTimeout(() => { location.href = r.helperUrl; }, 300);
|
|
523
|
+
} else if (r?.closeFrontend) {
|
|
524
|
+
setTimeout(() => { try { window.close(); } catch {} }, 400);
|
|
525
|
+
}
|
|
526
|
+
} catch (e) {
|
|
527
|
+
setUpgrading(false);
|
|
528
|
+
setToast(e.message, 'error');
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const current = info?.current || serverHealth.value.version || '';
|
|
533
|
+
const latest = info?.latest;
|
|
534
|
+
const updateAvailable = !!info?.updateAvailable;
|
|
535
|
+
|
|
536
|
+
return html`
|
|
537
|
+
<div class=${`version-card${updateAvailable ? ' has-update' : info?.error ? ' has-error' : ''}`}>
|
|
538
|
+
<div class="version-card-main">
|
|
539
|
+
<div class="version-card-current">
|
|
540
|
+
<span class="version-card-label">Installed</span>
|
|
541
|
+
<span class="version-card-version">v${current || '?'}</span>
|
|
542
|
+
${!updateAvailable && !info?.error && latest ? html`
|
|
543
|
+
<span class="version-card-badge">Latest</span>
|
|
544
|
+
` : null}
|
|
545
|
+
</div>
|
|
546
|
+
<div class="version-card-meta">
|
|
547
|
+
${info?.error
|
|
548
|
+
? html`<span class="version-card-error">Couldn't reach npm registry · <code>${info.error}</code></span>`
|
|
549
|
+
: updateAvailable
|
|
550
|
+
? html`Update available · <span class="mono">v${latest}</span>`
|
|
551
|
+
: latest
|
|
552
|
+
? `You're on the latest release. Checks npm registry (cached 30 min).`
|
|
553
|
+
: 'Checks npm registry (cached 30 min).'}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
<div class="version-card-actions">
|
|
557
|
+
${updateAvailable ? html`
|
|
558
|
+
<button class="action primary" disabled=${upgrading} onClick=${onUpgrade}>
|
|
559
|
+
${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
|
|
560
|
+
</button>
|
|
561
|
+
` : null}
|
|
562
|
+
<button class="action version-card-check" disabled=${checking || upgrading} onClick=${() => refresh(true)}>
|
|
563
|
+
<${IconRefresh} /> ${checking ? 'Checking…' : 'Check now'}
|
|
564
|
+
</button>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function RestartButton() {
|
|
571
|
+
const onClick = async () => {
|
|
572
|
+
const ok = await ccsmConfirm(
|
|
573
|
+
'Restart the ccsm backend? Active sessions will be killed and reattached on next launch.',
|
|
574
|
+
{ okLabel: 'Restart', danger: true });
|
|
575
|
+
if (!ok) return;
|
|
576
|
+
// Drop the fullscreen RestartOverlay BEFORE firing /api/restart —
|
|
577
|
+
// the request itself takes ~0ms (response is "ok, restarting") but
|
|
578
|
+
// the server then begins tearing PTYs down. If we wait for the
|
|
579
|
+
// response before opening the overlay, the user gets a frozen
|
|
580
|
+
// button + half-a-second of confusion.
|
|
581
|
+
const prevPid = serverHealth.value.pid || null;
|
|
582
|
+
restartInFlight.value = { startedAt: Date.now(), prevPid };
|
|
583
|
+
try {
|
|
584
|
+
const r = await restartBackend();
|
|
585
|
+
if (r?.closeFrontend) {
|
|
586
|
+
// Backend respawn will pop a fresh browser window — close this
|
|
587
|
+
// one so the user isn't stuck on the OfflineBanner during the
|
|
588
|
+
// ~3s downtime. window.close() only fires in script-opened
|
|
589
|
+
// windows (Edge --app=); regular tabs ignore it and stay open,
|
|
590
|
+
// which is the right behavior for them.
|
|
591
|
+
setTimeout(() => { try { window.close(); } catch {} }, 400);
|
|
592
|
+
}
|
|
593
|
+
// RestartOverlay self-dismisses once /api/health reports a fresh
|
|
594
|
+
// pid, so no further work here. If the new backend never comes
|
|
595
|
+
// back, the overlay has its own 30s safety timeout + OfflineBanner
|
|
596
|
+
// takes over.
|
|
597
|
+
} catch (e) {
|
|
598
|
+
restartInFlight.value = null;
|
|
599
|
+
setToast(e.message, 'error');
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
return html`
|
|
603
|
+
<div class="restart-button-wrap">
|
|
604
|
+
<button class="action" onClick=${onClick}>Restart backend</button>
|
|
605
|
+
</div>
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function ThemeToggle() {
|
|
610
|
+
const mode = themeMode.value;
|
|
611
|
+
const opts = [
|
|
612
|
+
{ id: 'light', label: 'Light', icon: IconSun },
|
|
613
|
+
{ id: 'dark', label: 'Dark', icon: IconMoon },
|
|
614
|
+
{ id: 'system', label: 'System', icon: IconMonitor },
|
|
615
|
+
];
|
|
616
|
+
return html`
|
|
617
|
+
<div class="seg" role="group" aria-label="Appearance">
|
|
618
|
+
${opts.map((o) => html`
|
|
619
|
+
<button key=${o.id} type="button"
|
|
620
|
+
class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
|
|
621
|
+
aria-pressed=${mode === o.id}
|
|
622
|
+
onClick=${() => setThemeMode(o.id)}>
|
|
623
|
+
<${o.icon} /><span>${o.label}</span>
|
|
624
|
+
</button>`)}
|
|
625
|
+
</div>`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function AccentPicker() {
|
|
629
|
+
const current = (accentColor.value || '').toLowerCase();
|
|
630
|
+
const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
|
|
631
|
+
const [customOpen, setCustomOpen] = useState(!matchedPreset);
|
|
632
|
+
const [text, setText] = useState(current);
|
|
633
|
+
useEffect(() => { setText(current); }, [current]);
|
|
634
|
+
|
|
635
|
+
const pickPreset = (hex) => {
|
|
636
|
+
setAccentColor(hex);
|
|
637
|
+
setCustomOpen(false);
|
|
638
|
+
};
|
|
639
|
+
const onText = (e) => {
|
|
640
|
+
const v = e.target.value.trim();
|
|
641
|
+
setText(v);
|
|
642
|
+
if (/^#[0-9a-fA-F]{6}$/.test(v)) setAccentColor(v);
|
|
643
|
+
};
|
|
644
|
+
return html`
|
|
645
|
+
<div class="accent-picker">
|
|
646
|
+
<div class="accent-chips">
|
|
647
|
+
${PRESETS.map((p) => {
|
|
648
|
+
const active = current === p.hex.toLowerCase();
|
|
649
|
+
return html`
|
|
650
|
+
<button key=${p.hex} type="button"
|
|
651
|
+
class=${`accent-chip${active ? ' is-active' : ''}`}
|
|
652
|
+
style=${`--c:${p.hex}`}
|
|
653
|
+
title=${p.hex}
|
|
654
|
+
onClick=${() => pickPreset(p.hex)}>
|
|
655
|
+
<span class="accent-chip-dot" aria-hidden="true"></span>
|
|
656
|
+
<span class="accent-chip-name">${p.name}</span>
|
|
657
|
+
</button>`;
|
|
658
|
+
})}
|
|
659
|
+
<button type="button"
|
|
660
|
+
class=${`accent-chip accent-chip-custom${customOpen ? ' is-open' : ''}${!matchedPreset ? ' is-active' : ''}`}
|
|
661
|
+
style=${!matchedPreset ? `--c:${current}` : ''}
|
|
662
|
+
onClick=${() => setCustomOpen((v) => !v)}>
|
|
663
|
+
${!matchedPreset
|
|
664
|
+
? html`<span class="accent-chip-dot" aria-hidden="true"></span>`
|
|
665
|
+
: html`<span class="accent-chip-plus" aria-hidden="true">+</span>`}
|
|
666
|
+
<span class="accent-chip-name">Custom</span>
|
|
667
|
+
</button>
|
|
668
|
+
</div>
|
|
669
|
+
${customOpen ? html`
|
|
670
|
+
<div class="accent-custom">
|
|
671
|
+
<input type="color" value=${current}
|
|
672
|
+
onInput=${(e) => setAccentColor(e.target.value)} />
|
|
673
|
+
<input type="text" class="accent-hex mono" value=${text}
|
|
674
|
+
spellcheck="false" maxlength="7"
|
|
675
|
+
onInput=${onText} placeholder="#rrggbb" />
|
|
676
|
+
<button type="button" class="accent-reset"
|
|
677
|
+
onClick=${() => { setAccentColor(ACCENT_DEFAULT); setCustomOpen(false); }}>
|
|
678
|
+
Reset
|
|
679
|
+
</button>
|
|
680
|
+
</div>` : null}
|
|
681
|
+
</div>`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
// ── Keyboard shortcuts ───────────────────────────────────────────────
|
|
686
|
+
const ACTION_ICONS = {
|
|
687
|
+
'session-next': IconChevronDown,
|
|
688
|
+
'session-prev': IconChevronUp,
|
|
689
|
+
'session-move-down': IconChevronDown,
|
|
690
|
+
'session-move-up': IconChevronUp,
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
function KeybindingsList() {
|
|
694
|
+
const map = keybindings.value;
|
|
695
|
+
const [recording, setRecording] = useState(null); // actionId or null
|
|
696
|
+
|
|
697
|
+
return html`
|
|
698
|
+
<div class="entity-list">
|
|
699
|
+
${Object.entries(ACTIONS).map(([id, def]) => {
|
|
700
|
+
const combo = map[id];
|
|
701
|
+
const isCustom = combo !== def.defaultCombo;
|
|
702
|
+
const Icon = ACTION_ICONS[id] || IconTerminal;
|
|
703
|
+
return html`
|
|
704
|
+
<div class="entity-row" key=${id}>
|
|
705
|
+
<span class="entity-row-icon"><${Icon} /></span>
|
|
706
|
+
<span class="entity-row-main">
|
|
707
|
+
<span class="entity-row-primary">
|
|
708
|
+
${def.label}
|
|
709
|
+
<span class="entity-row-badge tone-accent">${formatCombo(combo)}</span>
|
|
710
|
+
</span>
|
|
711
|
+
<span class="entity-row-secondary">
|
|
712
|
+
<span class="mono">${id}</span> · default <span class="mono">${formatCombo(def.defaultCombo)}</span>
|
|
713
|
+
</span>
|
|
714
|
+
</span>
|
|
715
|
+
<span class="entity-row-actions">
|
|
716
|
+
<button class="entity-row-action" title="Rebind"
|
|
717
|
+
onClick=${() => setRecording(id)}><${IconPencil} /></button>
|
|
718
|
+
${isCustom ? html`
|
|
719
|
+
<button class="entity-row-action" title="Reset to default"
|
|
720
|
+
onClick=${() => resetBinding(id)}><${IconRefresh} /></button>` : null}
|
|
721
|
+
</span>
|
|
722
|
+
</div>`;
|
|
723
|
+
})}
|
|
724
|
+
</div>
|
|
725
|
+
${recording ? html`
|
|
726
|
+
<${KeybindingRecorder}
|
|
727
|
+
actionLabel=${ACTIONS[recording]?.label || recording}
|
|
728
|
+
onCommit=${(combo) => { setBinding(recording, combo); setRecording(null); }}
|
|
729
|
+
onCancel=${() => setRecording(null)} />` : null}`;
|
|
730
|
+
}
|