@bakapiano/ccsm 0.14.0 → 0.15.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 +474 -475
- package/README.md +189 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliActivity.js +118 -0
- package/lib/codexSeed.js +147 -0
- package/lib/config.js +211 -188
- package/lib/folders.js +105 -105
- package/lib/localCliSessions.js +489 -489
- package/lib/persistedSessions.js +144 -142
- package/lib/webTerminal.js +224 -224
- package/lib/workspace.js +230 -230
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +613 -608
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1628 -1628
- package/public/index.html +111 -105
- package/public/js/api.js +296 -280
- package/public/js/components/AdoptModal.js +343 -343
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +141 -141
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +299 -299
- package/public/js/components/TerminalView.js +314 -314
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +132 -132
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +505 -475
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +101 -97
- package/public/js/state.js +231 -231
- package/scripts/dev.js +44 -11
- package/scripts/install.js +158 -158
- package/scripts/restart-helper.js +91 -0
- package/server.js +1278 -1254
- package/lib/cliSessionWatcher.js +0 -275
- package/public/manifest.webmanifest +0 -15
|
@@ -1,475 +1,505 @@
|
|
|
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,
|
|
9
|
-
setAccentColor, ACCENT_DEFAULT,
|
|
10
|
-
} from '../state.js';
|
|
11
|
-
import {
|
|
12
|
-
api, loadConfig, loadWorkspaces, loadFolders,
|
|
13
|
-
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
14
|
-
createRepo, updateRepo, deleteRepo,
|
|
15
|
-
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
16
|
-
deleteWorkspace,
|
|
17
|
-
} from '../api.js';
|
|
18
|
-
import { setToast } from '../toast.js';
|
|
19
|
-
import { ccsmConfirm } from '../dialog.js';
|
|
20
|
-
import { Card } from '../components/Card.js';
|
|
21
|
-
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
22
|
-
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
23
|
-
import { useDragSort } from '../components/useDragSort.js';
|
|
24
|
-
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
|
|
25
|
-
|
|
26
|
-
// Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
|
|
27
|
-
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
28
|
-
const CLI_TYPE_DEFAULTS = {
|
|
29
|
-
claude: { command: 'claude',
|
|
30
|
-
codex: { command: 'codex',
|
|
31
|
-
copilot: { command: 'copilot',
|
|
32
|
-
other: {
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function cliFieldsFor({ creating } = {}) {
|
|
36
|
-
return [
|
|
37
|
-
{ key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
|
|
38
|
-
{ value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
|
|
39
|
-
{ value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
|
|
40
|
-
{ value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
|
|
41
|
-
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
42
|
-
],
|
|
43
|
-
// When user picks a type while creating, prefill command + resumeArgs.
|
|
44
|
-
// For edit mode we don't override what the user already has.
|
|
45
|
-
onChange: creating ? (v, next) => {
|
|
46
|
-
const d = CLI_TYPE_DEFAULTS[v];
|
|
47
|
-
if (!d) return null;
|
|
48
|
-
const patch = {
|
|
49
|
-
if (!next.command || !next.command.trim()) patch.command = d.command || '';
|
|
50
|
-
if (!next.name || !next.name.trim()) {
|
|
51
|
-
patch.name = v === 'claude' ? 'Claude Code'
|
|
52
|
-
: v === 'codex' ? 'OpenAI Codex'
|
|
53
|
-
: v === 'copilot' ? 'GitHub Copilot'
|
|
54
|
-
: '';
|
|
55
|
-
}
|
|
56
|
-
return patch;
|
|
57
|
-
} : undefined,
|
|
58
|
-
},
|
|
59
|
-
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
60
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
|
|
61
|
-
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '',
|
|
62
|
-
hint: 'Used on every launch.' },
|
|
63
|
-
{ key: '
|
|
64
|
-
hint: '
|
|
65
|
-
{ key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
|
|
66
|
-
hint: '
|
|
67
|
-
{ key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
|
|
68
|
-
{ value: 'direct', label: 'direct (real .exe / .cmd)' },
|
|
69
|
-
{ value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
|
|
70
|
-
{ value: 'cmd', label: 'cmd (doskey)' },
|
|
71
|
-
] },
|
|
72
|
-
];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function Section({ title, meta, children }) {
|
|
76
|
-
return html`
|
|
77
|
-
<section class="settings-section">
|
|
78
|
-
<header class="settings-section-head">
|
|
79
|
-
<h2 class="settings-section-title">${title}</h2>
|
|
80
|
-
${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
|
|
81
|
-
</header>
|
|
82
|
-
<div class="settings-section-body">${children}</div>
|
|
83
|
-
</section>`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── Field definitions shared with Launch picker ──────────────────────
|
|
87
|
-
// (CLI fields built lazily via cliFieldsFor — see above.)
|
|
88
|
-
|
|
89
|
-
const repoFields = [
|
|
90
|
-
{ key: 'name', label: 'Name', placeholder: 'my-repo', autoFocus: true, required: true },
|
|
91
|
-
{ key: 'url', label: 'URL', mono: true, placeholder: 'https://github.com/me/foo.git', required: true },
|
|
92
|
-
{ key: 'defaultSelected', label: 'Pre-select on launch', type: 'checkbox',
|
|
93
|
-
hint: 'Auto-checked in the Repos picker for new sessions' },
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
const folderFields = [
|
|
97
|
-
{ key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
// ── Page ─────────────────────────────────────────────────────────────
|
|
101
|
-
export function ConfigurePage() {
|
|
102
|
-
const cfg = config.value;
|
|
103
|
-
const [edit, setEdit] = useState(null); // { kind, payload? }
|
|
104
|
-
const [general, setGeneral] = useState(null);
|
|
105
|
-
const [savedAt, setSavedAt] = useState('');
|
|
106
|
-
|
|
107
|
-
const folderDnd = useDragSort(
|
|
108
|
-
folders.value.map((f) => f.id),
|
|
109
|
-
async (nextIds) => {
|
|
110
|
-
try { await reorderFolders(nextIds); }
|
|
111
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
112
|
-
},
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
if (cfg && !general) {
|
|
117
|
-
setGeneral({ workDir: cfg.workDir });
|
|
118
|
-
}
|
|
119
|
-
}, [cfg]);
|
|
120
|
-
|
|
121
|
-
if (!cfg || !general) return null;
|
|
122
|
-
|
|
123
|
-
const saveGeneral = async (patch) => {
|
|
124
|
-
const merged = { ...general, ...patch };
|
|
125
|
-
setGeneral(merged);
|
|
126
|
-
try {
|
|
127
|
-
const saved = await api('PUT', '/api/config', {
|
|
128
|
-
...cfg,
|
|
129
|
-
workDir: (merged.workDir || '').trim(),
|
|
130
|
-
});
|
|
131
|
-
config.value = saved;
|
|
132
|
-
setToast('saved');
|
|
133
|
-
await loadWorkspaces();
|
|
134
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const close = () => setEdit(null);
|
|
138
|
-
|
|
139
|
-
return html`
|
|
140
|
-
<${PageTitleBar} title="Settings" />
|
|
141
|
-
<div class="settings-scroll">
|
|
142
|
-
|
|
143
|
-
<${Section} title="General">
|
|
144
|
-
<div class="config-grid">
|
|
145
|
-
<div class="field">
|
|
146
|
-
<span class="label">Theme accent</span>
|
|
147
|
-
<${AccentPicker} />
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
const
|
|
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
|
-
<span class="entity-row-
|
|
394
|
-
|
|
395
|
-
${
|
|
396
|
-
</span>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
{ name: '
|
|
416
|
-
{ name: '
|
|
417
|
-
{ name: '
|
|
418
|
-
{ name: '
|
|
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
|
-
}
|
|
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,
|
|
9
|
+
setAccentColor, ACCENT_DEFAULT,
|
|
10
|
+
} from '../state.js';
|
|
11
|
+
import {
|
|
12
|
+
api, loadConfig, loadWorkspaces, loadFolders,
|
|
13
|
+
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
14
|
+
createRepo, updateRepo, deleteRepo,
|
|
15
|
+
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
16
|
+
deleteWorkspace, restartBackend,
|
|
17
|
+
} from '../api.js';
|
|
18
|
+
import { setToast } from '../toast.js';
|
|
19
|
+
import { ccsmConfirm } from '../dialog.js';
|
|
20
|
+
import { Card } from '../components/Card.js';
|
|
21
|
+
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
22
|
+
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
23
|
+
import { useDragSort } from '../components/useDragSort.js';
|
|
24
|
+
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
|
|
25
|
+
|
|
26
|
+
// Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
|
|
27
|
+
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
28
|
+
const CLI_TYPE_DEFAULTS = {
|
|
29
|
+
claude: { command: 'claude', resumeIdArgs: '--resume <id>', newSessionIdArgs: '--session-id <id>' },
|
|
30
|
+
codex: { command: 'codex', resumeIdArgs: 'resume <id>', newSessionIdArgs: 'resume <id>' },
|
|
31
|
+
copilot: { command: 'copilot', resumeIdArgs: '--resume <id>', newSessionIdArgs: '--session-id <id>' },
|
|
32
|
+
other: { resumeIdArgs: '', newSessionIdArgs: '' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function cliFieldsFor({ creating } = {}) {
|
|
36
|
+
return [
|
|
37
|
+
{ key: 'type', label: 'Type', type: 'iconRadio', default: 'other', options: [
|
|
38
|
+
{ value: 'claude', label: 'Claude CLI', icon: html`<${IconClaudeColor} />` },
|
|
39
|
+
{ value: 'codex', label: 'Codex CLI', icon: html`<${IconCodexColor} />` },
|
|
40
|
+
{ value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
|
|
41
|
+
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
42
|
+
],
|
|
43
|
+
// When user picks a type while creating, prefill command + resumeArgs.
|
|
44
|
+
// For edit mode we don't override what the user already has.
|
|
45
|
+
onChange: creating ? (v, next) => {
|
|
46
|
+
const d = CLI_TYPE_DEFAULTS[v];
|
|
47
|
+
if (!d) return null;
|
|
48
|
+
const patch = { resumeIdArgs: d.resumeIdArgs, newSessionIdArgs: d.newSessionIdArgs };
|
|
49
|
+
if (!next.command || !next.command.trim()) patch.command = d.command || '';
|
|
50
|
+
if (!next.name || !next.name.trim()) {
|
|
51
|
+
patch.name = v === 'claude' ? 'Claude Code'
|
|
52
|
+
: v === 'codex' ? 'OpenAI Codex'
|
|
53
|
+
: v === 'copilot' ? 'GitHub Copilot'
|
|
54
|
+
: '';
|
|
55
|
+
}
|
|
56
|
+
return patch;
|
|
57
|
+
} : undefined,
|
|
58
|
+
},
|
|
59
|
+
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
60
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
|
|
61
|
+
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '',
|
|
62
|
+
hint: 'Used on every launch.' },
|
|
63
|
+
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
64
|
+
hint: 'ccsm pre-generates a UUID and substitutes it for <id> on first launch — the upstream CLI session id is known immediately.' },
|
|
65
|
+
{ key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
|
|
66
|
+
hint: 'Used on every resume. Substitutes <id> with the captured session UUID.' },
|
|
67
|
+
{ key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
|
|
68
|
+
{ value: 'direct', label: 'direct (real .exe / .cmd)' },
|
|
69
|
+
{ value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
|
|
70
|
+
{ value: 'cmd', label: 'cmd (doskey)' },
|
|
71
|
+
] },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Section({ title, meta, children }) {
|
|
76
|
+
return html`
|
|
77
|
+
<section class="settings-section">
|
|
78
|
+
<header class="settings-section-head">
|
|
79
|
+
<h2 class="settings-section-title">${title}</h2>
|
|
80
|
+
${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
|
|
81
|
+
</header>
|
|
82
|
+
<div class="settings-section-body">${children}</div>
|
|
83
|
+
</section>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Field definitions shared with Launch picker ──────────────────────
|
|
87
|
+
// (CLI fields built lazily via cliFieldsFor — see above.)
|
|
88
|
+
|
|
89
|
+
const repoFields = [
|
|
90
|
+
{ key: 'name', label: 'Name', placeholder: 'my-repo', autoFocus: true, required: true },
|
|
91
|
+
{ key: 'url', label: 'URL', mono: true, placeholder: 'https://github.com/me/foo.git', required: true },
|
|
92
|
+
{ key: 'defaultSelected', label: 'Pre-select on launch', type: 'checkbox',
|
|
93
|
+
hint: 'Auto-checked in the Repos picker for new sessions' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const folderFields = [
|
|
97
|
+
{ key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ── Page ─────────────────────────────────────────────────────────────
|
|
101
|
+
export function ConfigurePage() {
|
|
102
|
+
const cfg = config.value;
|
|
103
|
+
const [edit, setEdit] = useState(null); // { kind, payload? }
|
|
104
|
+
const [general, setGeneral] = useState(null);
|
|
105
|
+
const [savedAt, setSavedAt] = useState('');
|
|
106
|
+
|
|
107
|
+
const folderDnd = useDragSort(
|
|
108
|
+
folders.value.map((f) => f.id),
|
|
109
|
+
async (nextIds) => {
|
|
110
|
+
try { await reorderFolders(nextIds); }
|
|
111
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (cfg && !general) {
|
|
117
|
+
setGeneral({ workDir: cfg.workDir });
|
|
118
|
+
}
|
|
119
|
+
}, [cfg]);
|
|
120
|
+
|
|
121
|
+
if (!cfg || !general) return null;
|
|
122
|
+
|
|
123
|
+
const saveGeneral = async (patch) => {
|
|
124
|
+
const merged = { ...general, ...patch };
|
|
125
|
+
setGeneral(merged);
|
|
126
|
+
try {
|
|
127
|
+
const saved = await api('PUT', '/api/config', {
|
|
128
|
+
...cfg,
|
|
129
|
+
workDir: (merged.workDir || '').trim(),
|
|
130
|
+
});
|
|
131
|
+
config.value = saved;
|
|
132
|
+
setToast('saved');
|
|
133
|
+
await loadWorkspaces();
|
|
134
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const close = () => setEdit(null);
|
|
138
|
+
|
|
139
|
+
return html`
|
|
140
|
+
<${PageTitleBar} title="Settings" />
|
|
141
|
+
<div class="settings-scroll">
|
|
142
|
+
|
|
143
|
+
<${Section} title="General">
|
|
144
|
+
<div class="config-grid">
|
|
145
|
+
<div class="field">
|
|
146
|
+
<span class="label">Theme accent</span>
|
|
147
|
+
<${AccentPicker} />
|
|
148
|
+
</div>
|
|
149
|
+
<div class="field">
|
|
150
|
+
<span class="label">Backend</span>
|
|
151
|
+
<${RestartButton} />
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</${Section}>
|
|
155
|
+
|
|
156
|
+
<${Section} title="CLIs" meta=${html`Built-in entries (<code>claude</code>, <code>codex</code>) auto-probe your PATH.`}>
|
|
157
|
+
<${EntityList}
|
|
158
|
+
kind="cli"
|
|
159
|
+
addLabel="Add CLI"
|
|
160
|
+
items=${(cfg.clis || []).map((c) => {
|
|
161
|
+
const tags = [];
|
|
162
|
+
if (cfg.defaultCliId === c.id) tags.push({ label: 'default', tone: 'accent' });
|
|
163
|
+
if (c.builtin) tags.push({ label: c.installed ? 'installed' : 'not found', tone: c.installed ? 'ok' : 'warn' });
|
|
164
|
+
const Icon = IconForCliType(c.type);
|
|
165
|
+
return {
|
|
166
|
+
id: c.id,
|
|
167
|
+
icon: html`<${Icon} />`,
|
|
168
|
+
primary: c.name,
|
|
169
|
+
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + c.args.join(' ') : ''}</span>${c.shell && c.shell !== 'direct' ? html` · ${c.shell}` : null}`,
|
|
170
|
+
badges: tags,
|
|
171
|
+
undeletable: c.builtin,
|
|
172
|
+
raw: c,
|
|
173
|
+
};
|
|
174
|
+
})}
|
|
175
|
+
onAdd=${() => setEdit({ kind: 'cli-new' })}
|
|
176
|
+
onEdit=${(it) => setEdit({ kind: 'cli-edit', payload: it.raw })}
|
|
177
|
+
onDelete=${async (it) => {
|
|
178
|
+
if (it.undeletable) return setToast(`"${it.primary}" is built-in and can't be deleted`, 'error');
|
|
179
|
+
if (cfg.clis.length === 1) return setToast('cannot delete the last CLI', 'error');
|
|
180
|
+
const ok = await ccsmConfirm(`Delete CLI "${it.primary}"?`, { okLabel: 'Delete', danger: true });
|
|
181
|
+
if (!ok) return;
|
|
182
|
+
try { await deleteCli(it.id); setToast('deleted'); }
|
|
183
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
184
|
+
}}
|
|
185
|
+
onActivate=${async (it) => {
|
|
186
|
+
if (cfg.defaultCliId === it.id) return;
|
|
187
|
+
try { await setDefaultCli(it.id); setToast(`default · ${it.primary}`); }
|
|
188
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
189
|
+
}}
|
|
190
|
+
emptyHint="No CLIs configured."
|
|
191
|
+
/>
|
|
192
|
+
</${Section}>
|
|
193
|
+
|
|
194
|
+
<${Section} title="Repositories" meta="Available for clone-on-launch into a new workspace.">
|
|
195
|
+
<${EntityList}
|
|
196
|
+
kind="repo"
|
|
197
|
+
addLabel="Add Repo"
|
|
198
|
+
items=${(cfg.repos || []).map((r) => ({
|
|
199
|
+
id: r.name,
|
|
200
|
+
icon: html`<${IconBranch} />`,
|
|
201
|
+
primary: r.name,
|
|
202
|
+
secondary: html`<span class="mono">${r.url}</span>`,
|
|
203
|
+
badge: r.defaultSelected ? 'auto' : null,
|
|
204
|
+
raw: r,
|
|
205
|
+
}))}
|
|
206
|
+
onAdd=${() => setEdit({ kind: 'repo-new' })}
|
|
207
|
+
onEdit=${(it) => setEdit({ kind: 'repo-edit', payload: it.raw })}
|
|
208
|
+
onDelete=${async (it) => {
|
|
209
|
+
const ok = await ccsmConfirm(`Remove repo "${it.primary}" from the list?`, { okLabel: 'Remove', danger: true });
|
|
210
|
+
if (!ok) return;
|
|
211
|
+
try { await deleteRepo(it.id); setToast('removed'); }
|
|
212
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
213
|
+
}}
|
|
214
|
+
emptyHint="No repos configured."
|
|
215
|
+
/>
|
|
216
|
+
</${Section}>
|
|
217
|
+
|
|
218
|
+
<${Section} title="Folders" meta="Buckets that group sessions in the sidebar.">
|
|
219
|
+
<${EntityList}
|
|
220
|
+
kind="folder"
|
|
221
|
+
addLabel="Add Folder"
|
|
222
|
+
dnd=${folderDnd}
|
|
223
|
+
items=${folders.value.map((f) => ({
|
|
224
|
+
id: f.id,
|
|
225
|
+
icon: html`<${IconFolder} />`,
|
|
226
|
+
primary: f.name,
|
|
227
|
+
secondary: null,
|
|
228
|
+
raw: f,
|
|
229
|
+
}))}
|
|
230
|
+
onAdd=${() => setEdit({ kind: 'folder-new' })}
|
|
231
|
+
onEdit=${(it) => setEdit({ kind: 'folder-edit', payload: it.raw })}
|
|
232
|
+
onDelete=${async (it) => {
|
|
233
|
+
const ok = await ccsmConfirm(`Delete folder "${it.primary}"? Sessions inside move to Unsorted.`, { okLabel: 'Delete', danger: true });
|
|
234
|
+
if (!ok) return;
|
|
235
|
+
try { await deleteFolder(it.id); setToast('deleted'); }
|
|
236
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
237
|
+
}}
|
|
238
|
+
emptyHint="No folders yet."
|
|
239
|
+
/>
|
|
240
|
+
</${Section}>
|
|
241
|
+
|
|
242
|
+
<${Section} title="Workspaces"
|
|
243
|
+
meta=${html`Auto-allocated <code>ws-N</code> folders under the work directory. Each holds one or more repo clones.`}>
|
|
244
|
+
<div class="config-grid">
|
|
245
|
+
<label class="field">
|
|
246
|
+
<span class="label">Work directory</span>
|
|
247
|
+
<input type="text" value=${general.workDir}
|
|
248
|
+
onChange=${(e) => saveGeneral({ workDir: e.target.value })} />
|
|
249
|
+
</label>
|
|
250
|
+
</div>
|
|
251
|
+
<${WorkspaceList} />
|
|
252
|
+
</${Section}>
|
|
253
|
+
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
${edit?.kind === 'cli-new' ? html`
|
|
257
|
+
<${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
|
|
258
|
+
onClose=${close} submitLabel="Create"
|
|
259
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
260
|
+
onSubmit=${async (v) => {
|
|
261
|
+
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
262
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
263
|
+
}} />` : null}
|
|
264
|
+
|
|
265
|
+
${edit?.kind === 'cli-edit' ? html`
|
|
266
|
+
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
|
|
267
|
+
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
268
|
+
initial=${{
|
|
269
|
+
...edit.payload,
|
|
270
|
+
args: (edit.payload.args || []).join(' '),
|
|
271
|
+
resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
|
|
272
|
+
newSessionIdArgs: (edit.payload.newSessionIdArgs || []).join(' '),
|
|
273
|
+
}}
|
|
274
|
+
onClose=${close}
|
|
275
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
276
|
+
onSubmit=${async (v) => {
|
|
277
|
+
try {
|
|
278
|
+
const patch = {
|
|
279
|
+
...v,
|
|
280
|
+
args: typeof v.args === 'string' ? v.args.split(/\s+/).filter(Boolean) : v.args,
|
|
281
|
+
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
282
|
+
newSessionIdArgs: typeof v.newSessionIdArgs === 'string' ? v.newSessionIdArgs.split(/\s+/).filter(Boolean) : v.newSessionIdArgs,
|
|
283
|
+
};
|
|
284
|
+
await updateCli(edit.payload.id, patch);
|
|
285
|
+
setToast('saved');
|
|
286
|
+
} catch (e) { setToast(e.message, 'error'); throw e; }
|
|
287
|
+
}} />` : null}
|
|
288
|
+
|
|
289
|
+
${edit?.kind === 'repo-new' ? html`
|
|
290
|
+
<${EntityFormModal} title="New repo" fields=${repoFields}
|
|
291
|
+
onClose=${close} submitLabel="Add"
|
|
292
|
+
onSubmit=${async (v) => {
|
|
293
|
+
try { await createRepo(v); setToast(`added repo · ${v.name}`); }
|
|
294
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
295
|
+
}} />` : null}
|
|
296
|
+
|
|
297
|
+
${edit?.kind === 'repo-edit' ? html`
|
|
298
|
+
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${repoFields}
|
|
299
|
+
initial=${edit.payload}
|
|
300
|
+
onClose=${close}
|
|
301
|
+
onSubmit=${async (v) => {
|
|
302
|
+
try { await updateRepo(edit.payload.name, v); setToast('saved'); }
|
|
303
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
304
|
+
}} />` : null}
|
|
305
|
+
|
|
306
|
+
${edit?.kind === 'folder-new' ? html`
|
|
307
|
+
<${EntityFormModal} title="New folder" fields=${folderFields}
|
|
308
|
+
onClose=${close} submitLabel="Create"
|
|
309
|
+
onSubmit=${async (v) => {
|
|
310
|
+
try { await createFolder(v.name); await loadFolders(); setToast(`created folder · ${v.name}`); }
|
|
311
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
312
|
+
}} />` : null}
|
|
313
|
+
|
|
314
|
+
${edit?.kind === 'folder-edit' ? html`
|
|
315
|
+
<${EntityFormModal} title=${`Rename ${edit.payload.name}`} fields=${folderFields}
|
|
316
|
+
initial=${edit.payload}
|
|
317
|
+
onClose=${close}
|
|
318
|
+
onSubmit=${async (v) => {
|
|
319
|
+
try { await renameFolder(edit.payload.id, v.name.trim()); await loadFolders(); setToast('renamed'); }
|
|
320
|
+
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
321
|
+
}} />` : null}
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Generic "list of rows + Add button" used by all three sections.
|
|
326
|
+
function EntityList({ items, onAdd, onEdit, onDelete, onActivate, emptyHint, dnd, addLabel = 'Add' }) {
|
|
327
|
+
return html`
|
|
328
|
+
<div class="entity-list">
|
|
329
|
+
${items.length === 0
|
|
330
|
+
? html`<div class="entity-empty">${emptyHint}</div>`
|
|
331
|
+
: items.map((it) => {
|
|
332
|
+
const rowProps = dnd ? dnd.rowProps(it.id) : {};
|
|
333
|
+
const handleProps = dnd ? dnd.handleProps(it.id) : {};
|
|
334
|
+
const badges = it.badges || (it.badge ? [{ label: it.badge, tone: 'accent' }] : []);
|
|
335
|
+
return html`
|
|
336
|
+
<div class=${`entity-row${dnd ? ' is-draggable' : ''}`} key=${it.id}
|
|
337
|
+
...${rowProps} ...${handleProps}>
|
|
338
|
+
${dnd ? html`<span class="entity-row-grip" aria-hidden="true">⋮⋮</span>` : null}
|
|
339
|
+
<span class="entity-row-icon">${it.icon}</span>
|
|
340
|
+
<span class="entity-row-main">
|
|
341
|
+
<span class="entity-row-primary">
|
|
342
|
+
${it.primary}
|
|
343
|
+
${badges.map((b) => html`
|
|
344
|
+
<span class=${`entity-row-badge tone-${b.tone || 'accent'}`}>${b.label}</span>`)}
|
|
345
|
+
</span>
|
|
346
|
+
${it.secondary ? html`<span class="entity-row-secondary">${it.secondary}</span>` : null}
|
|
347
|
+
</span>
|
|
348
|
+
<span class="entity-row-actions">
|
|
349
|
+
${onActivate ? html`
|
|
350
|
+
<button class="entity-row-action" title="Set default"
|
|
351
|
+
onClick=${() => onActivate(it)}>★</button>` : null}
|
|
352
|
+
<button class="entity-row-action" title="Edit"
|
|
353
|
+
onClick=${() => onEdit(it)}><${IconPencil} /></button>
|
|
354
|
+
${it.undeletable ? null : html`
|
|
355
|
+
<button class="entity-row-action danger" title="Delete"
|
|
356
|
+
onClick=${() => onDelete(it)}><${IconClose} /></button>`}
|
|
357
|
+
</span>
|
|
358
|
+
</div>`;
|
|
359
|
+
})}
|
|
360
|
+
<button class="entity-add" type="button" onClick=${onAdd}>
|
|
361
|
+
<span>${addLabel}</span>
|
|
362
|
+
</button>
|
|
363
|
+
</div>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Workspace list ───────────────────────────────────────────────────
|
|
367
|
+
function WorkspaceList() {
|
|
368
|
+
const ws = workspaces.value || [];
|
|
369
|
+
if (ws.length === 0) {
|
|
370
|
+
return html`<div class="entity-empty">No workspaces yet — they're created automatically on launch.</div>`;
|
|
371
|
+
}
|
|
372
|
+
const onDelete = async (w) => {
|
|
373
|
+
if (w.inUse) return setToast(`"${w.name}" is in use by a running session`, 'error');
|
|
374
|
+
const ok = await ccsmConfirm(
|
|
375
|
+
`Delete workspace "${w.name}"? This removes the directory and all repo clones inside.`,
|
|
376
|
+
{ okLabel: 'Delete', danger: true },
|
|
377
|
+
);
|
|
378
|
+
if (!ok) return;
|
|
379
|
+
try {
|
|
380
|
+
await deleteWorkspace(w.name);
|
|
381
|
+
await loadWorkspaces();
|
|
382
|
+
setToast(`deleted · ${w.name}`);
|
|
383
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
384
|
+
};
|
|
385
|
+
return html`
|
|
386
|
+
<div class="entity-list">
|
|
387
|
+
${ws.map((w) => {
|
|
388
|
+
const repoCount = (w.repos || []).filter((r) => r.exists).length;
|
|
389
|
+
return html`
|
|
390
|
+
<div class="entity-row" key=${w.path}>
|
|
391
|
+
<span class="entity-row-icon"><${IconFolder} /></span>
|
|
392
|
+
<span class="entity-row-main">
|
|
393
|
+
<span class="entity-row-primary">
|
|
394
|
+
${w.name}
|
|
395
|
+
${w.inUse ? html`<span class="entity-row-badge tone-warn">in use</span>` : null}
|
|
396
|
+
</span>
|
|
397
|
+
<span class="entity-row-secondary">
|
|
398
|
+
<span class="mono">${w.path}</span>
|
|
399
|
+
${repoCount > 0 ? html` · ${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}` : null}
|
|
400
|
+
</span>
|
|
401
|
+
</span>
|
|
402
|
+
<span class="entity-row-actions">
|
|
403
|
+
<button class=${`entity-row-action danger${w.inUse ? ' is-disabled' : ''}`}
|
|
404
|
+
title=${w.inUse ? 'In use by a running session' : 'Delete'}
|
|
405
|
+
disabled=${w.inUse}
|
|
406
|
+
onClick=${() => onDelete(w)}><${IconClose} /></button>
|
|
407
|
+
</span>
|
|
408
|
+
</div>`;
|
|
409
|
+
})}
|
|
410
|
+
</div>`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Accent picker (unchanged) ────────────────────────────────────────
|
|
414
|
+
const PRESETS = [
|
|
415
|
+
{ name: 'Ocean', hex: '#2f6fa3' },
|
|
416
|
+
{ name: 'Claude copper', hex: '#b3614a' },
|
|
417
|
+
{ name: 'Anthropic ink', hex: '#1a1815' },
|
|
418
|
+
{ name: 'Forest', hex: '#3f7a4a' },
|
|
419
|
+
{ name: 'Amber', hex: '#c4892b' },
|
|
420
|
+
{ name: 'Berry', hex: '#a44b78' },
|
|
421
|
+
{ name: 'Slate', hex: '#4a5563' },
|
|
422
|
+
{ name: 'Crimson', hex: '#b73f3f' },
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
function RestartButton() {
|
|
426
|
+
const [busy, setBusy] = useState(false);
|
|
427
|
+
const onClick = async () => {
|
|
428
|
+
const ok = await ccsmConfirm(
|
|
429
|
+
'Restart the ccsm backend? Active sessions will be killed and reattached on next launch.',
|
|
430
|
+
{ okLabel: 'Restart', danger: true });
|
|
431
|
+
if (!ok) return;
|
|
432
|
+
setBusy(true);
|
|
433
|
+
try {
|
|
434
|
+
await restartBackend();
|
|
435
|
+
setToast('restarting backend…');
|
|
436
|
+
} catch (e) {
|
|
437
|
+
setBusy(false);
|
|
438
|
+
setToast(e.message, 'error');
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
return html`
|
|
442
|
+
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
|
443
|
+
<button class="action" disabled=${busy} onClick=${onClick}>
|
|
444
|
+
${busy ? 'Restarting…' : 'Restart backend'}
|
|
445
|
+
</button>
|
|
446
|
+
<span class="hint">Stops the server, then spawns a fresh one on the same port.</span>
|
|
447
|
+
</div>
|
|
448
|
+
`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function AccentPicker() {
|
|
452
|
+
const current = (accentColor.value || '').toLowerCase();
|
|
453
|
+
const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
|
|
454
|
+
const [customOpen, setCustomOpen] = useState(!matchedPreset);
|
|
455
|
+
const [text, setText] = useState(current);
|
|
456
|
+
useEffect(() => { setText(current); }, [current]);
|
|
457
|
+
|
|
458
|
+
const pickPreset = (hex) => {
|
|
459
|
+
setAccentColor(hex);
|
|
460
|
+
setCustomOpen(false);
|
|
461
|
+
};
|
|
462
|
+
const onText = (e) => {
|
|
463
|
+
const v = e.target.value.trim();
|
|
464
|
+
setText(v);
|
|
465
|
+
if (/^#[0-9a-fA-F]{6}$/.test(v)) setAccentColor(v);
|
|
466
|
+
};
|
|
467
|
+
return html`
|
|
468
|
+
<div class="accent-picker">
|
|
469
|
+
<div class="accent-chips">
|
|
470
|
+
${PRESETS.map((p) => {
|
|
471
|
+
const active = current === p.hex.toLowerCase();
|
|
472
|
+
return html`
|
|
473
|
+
<button key=${p.hex} type="button"
|
|
474
|
+
class=${`accent-chip${active ? ' is-active' : ''}`}
|
|
475
|
+
style=${`--c:${p.hex}`}
|
|
476
|
+
title=${p.hex}
|
|
477
|
+
onClick=${() => pickPreset(p.hex)}>
|
|
478
|
+
<span class="accent-chip-dot" aria-hidden="true"></span>
|
|
479
|
+
<span class="accent-chip-name">${p.name}</span>
|
|
480
|
+
</button>`;
|
|
481
|
+
})}
|
|
482
|
+
<button type="button"
|
|
483
|
+
class=${`accent-chip accent-chip-custom${customOpen ? ' is-open' : ''}${!matchedPreset ? ' is-active' : ''}`}
|
|
484
|
+
style=${!matchedPreset ? `--c:${current}` : ''}
|
|
485
|
+
onClick=${() => setCustomOpen((v) => !v)}>
|
|
486
|
+
${!matchedPreset
|
|
487
|
+
? html`<span class="accent-chip-dot" aria-hidden="true"></span>`
|
|
488
|
+
: html`<span class="accent-chip-plus" aria-hidden="true">+</span>`}
|
|
489
|
+
<span class="accent-chip-name">Custom</span>
|
|
490
|
+
</button>
|
|
491
|
+
</div>
|
|
492
|
+
${customOpen ? html`
|
|
493
|
+
<div class="accent-custom">
|
|
494
|
+
<input type="color" value=${current}
|
|
495
|
+
onInput=${(e) => setAccentColor(e.target.value)} />
|
|
496
|
+
<input type="text" class="accent-hex mono" value=${text}
|
|
497
|
+
spellcheck="false" maxlength="7"
|
|
498
|
+
onInput=${onText} placeholder="#rrggbb" />
|
|
499
|
+
<button type="button" class="accent-reset"
|
|
500
|
+
onClick=${() => { setAccentColor(ACCENT_DEFAULT); setCustomOpen(false); }}>
|
|
501
|
+
Reset
|
|
502
|
+
</button>
|
|
503
|
+
</div>` : null}
|
|
504
|
+
</div>`;
|
|
505
|
+
}
|