claude-code-hub 1.34.0 → 1.35.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/lib/keymap.js +1 -1
- package/lib/onboarding.js +59 -0
- package/lib/settings.js +8 -1
- package/lib/tray.js +37 -17
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +13 -0
- package/packages/claude-hub-sdk/src/keys.js +52 -9
- package/packages/claude-hub-sdk/src/stub.js +1 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/keys.md +4 -2
- package/public/app.js +674 -21
- package/public/index.html +156 -2
- package/server.js +119 -3
package/public/app.js
CHANGED
|
@@ -3,7 +3,7 @@ let apps = {};
|
|
|
3
3
|
let keymap = null;
|
|
4
4
|
// config.json `keys`, {actionId: combo | null}, from /api/config.
|
|
5
5
|
let userKeys = {};
|
|
6
|
-
const { comboOf, keyParts } = ClaudeHubKeys;
|
|
6
|
+
const { comboOf, codeOf, keyParts } = ClaudeHubKeys;
|
|
7
7
|
let activeApp = null;
|
|
8
8
|
// The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
|
|
9
9
|
let previousApp = null;
|
|
@@ -305,13 +305,20 @@ async function init() {
|
|
|
305
305
|
switchTab(tabs.includes(saved) ? saved : (tabs[0] ?? Object.keys(apps)[0]));
|
|
306
306
|
listenMessages();
|
|
307
307
|
listenKeys();
|
|
308
|
+
window.addEventListener('keydown', onShortcutKey, true);
|
|
309
|
+
const keysOverlay = document.getElementById('shortcuts');
|
|
310
|
+
keysOverlay.addEventListener('mousedown', (e) => {
|
|
311
|
+
if (e.target === keysOverlay) closeShortcuts();
|
|
312
|
+
});
|
|
308
313
|
if (voiceOn) listenVoiceRelease();
|
|
309
314
|
if (config.voice?.auto && voiceInBrowser()) noteBrowserVoice();
|
|
310
315
|
bindPalette();
|
|
311
316
|
bindSettings();
|
|
317
|
+
bindSetup();
|
|
312
318
|
registerSW();
|
|
313
319
|
bindErrorToast();
|
|
314
320
|
showUpdateToast();
|
|
321
|
+
if (!config.onboarding) maybeShowSetup();
|
|
315
322
|
reloadOnHubRestart();
|
|
316
323
|
}
|
|
317
324
|
|
|
@@ -342,6 +349,7 @@ async function showUpdateToast() {
|
|
|
342
349
|
} catch {
|
|
343
350
|
return;
|
|
344
351
|
}
|
|
352
|
+
openUpdateAfterUpgrade(info);
|
|
345
353
|
const latest = info.update?.latest;
|
|
346
354
|
let dismissed = null;
|
|
347
355
|
try {
|
|
@@ -351,6 +359,7 @@ async function showUpdateToast() {
|
|
|
351
359
|
const toast = document.getElementById('update-toast');
|
|
352
360
|
document.getElementById('update-latest').textContent = latest;
|
|
353
361
|
document.getElementById('update-current').textContent = info.version;
|
|
362
|
+
document.getElementById('update-command').textContent = HUB_UPDATE_COMMAND;
|
|
354
363
|
document.getElementById('update-notes').href = info.update.url;
|
|
355
364
|
let timer = 0;
|
|
356
365
|
const hide = () => {
|
|
@@ -361,6 +370,10 @@ async function showUpdateToast() {
|
|
|
361
370
|
toast.addEventListener('mouseenter', () => clearTimeout(timer));
|
|
362
371
|
toast.addEventListener('mouseleave', arm);
|
|
363
372
|
document.getElementById('update-close').addEventListener('click', hide);
|
|
373
|
+
document.getElementById('update-review').addEventListener('click', () => {
|
|
374
|
+
hide();
|
|
375
|
+
openUpdate();
|
|
376
|
+
});
|
|
364
377
|
document.getElementById('update-skip').addEventListener('click', () => {
|
|
365
378
|
try {
|
|
366
379
|
localStorage.setItem(UPDATE_DISMISSED_KEY, latest);
|
|
@@ -371,6 +384,28 @@ async function showUpdateToast() {
|
|
|
371
384
|
arm();
|
|
372
385
|
}
|
|
373
386
|
|
|
387
|
+
// A new hub version ships new plugins, so the first load after an upgrade offers to update them.
|
|
388
|
+
const LAST_VERSION_KEY = 'hub-last-version';
|
|
389
|
+
|
|
390
|
+
async function openUpdateAfterUpgrade(info) {
|
|
391
|
+
let last = null;
|
|
392
|
+
try {
|
|
393
|
+
last = localStorage.getItem(LAST_VERSION_KEY);
|
|
394
|
+
localStorage.setItem(LAST_VERSION_KEY, info.version);
|
|
395
|
+
} catch {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!last || last === info.version) return;
|
|
399
|
+
let data;
|
|
400
|
+
try {
|
|
401
|
+
data = await sendJson('GET', '/api/setup');
|
|
402
|
+
} catch {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (data.pluginsDone || openOverlay) return;
|
|
406
|
+
openSetup(data, 'update', info);
|
|
407
|
+
}
|
|
408
|
+
|
|
374
409
|
function showToast(toast) {
|
|
375
410
|
toast.classList.remove('leaving');
|
|
376
411
|
toast.hidden = false;
|
|
@@ -857,10 +892,21 @@ function runVoiceCommand({ text, command = {} }) {
|
|
|
857
892
|
showErrorToast("Didn't get that", new Error(`“${text}”`));
|
|
858
893
|
}
|
|
859
894
|
|
|
860
|
-
// `voice.dictate`,
|
|
895
|
+
// `voice.dictate`, an action the hub handles itself: an app's mic gets the provider's text with no command.
|
|
861
896
|
const DICTATE = 'voice.dictate';
|
|
897
|
+
const HUB_UPDATES = 'hub.updates';
|
|
862
898
|
// With the browser provider an app's mic runs its own browser speech, so the hub does not offer it.
|
|
863
899
|
const hubDictates = () => voiceOn && !voiceInBrowser();
|
|
900
|
+
const HUB_OWNED = {
|
|
901
|
+
[DICTATE]: { available: hubDictates, run: (params, reply) => dictate(params.op, reply) },
|
|
902
|
+
[HUB_UPDATES]: {
|
|
903
|
+
available: () => true,
|
|
904
|
+
run: (_params, reply) => {
|
|
905
|
+
openUpdate();
|
|
906
|
+
reply({ ok: true, handledBy: 'hub' });
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
};
|
|
864
910
|
|
|
865
911
|
async function dictate(op, reply) {
|
|
866
912
|
const busy = op === 'start' ? voice.state !== 'idle' : voice.state !== 'dictating';
|
|
@@ -1112,10 +1158,9 @@ function welcomeFor(data, appId) {
|
|
|
1112
1158
|
return {
|
|
1113
1159
|
type: 'hub:welcome',
|
|
1114
1160
|
protocol: Math.max(...common),
|
|
1115
|
-
|
|
1116
|
-
keys: actionKeys(),
|
|
1161
|
+
...keyFields(),
|
|
1117
1162
|
themes: hubThemes,
|
|
1118
|
-
actions: [...Object.keys(actions), ...(
|
|
1163
|
+
actions: [...Object.keys(actions), ...Object.keys(HUB_OWNED).filter((id) => HUB_OWNED[id].available())],
|
|
1119
1164
|
origins: Object.keys(apps).map(originOf),
|
|
1120
1165
|
settings: apps[appId]?.settings ?? {},
|
|
1121
1166
|
voice: voiceOn
|
|
@@ -1134,6 +1179,19 @@ function sendSettings(appId, list) {
|
|
|
1134
1179
|
for (const [win, nested] of nestedApps) if (nested.appId === appId) postNested(win, message);
|
|
1135
1180
|
}
|
|
1136
1181
|
|
|
1182
|
+
function keyFields() {
|
|
1183
|
+
return { forward: Object.keys(bindings()), keys: actionKeys() };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// The user's keys after a save in the shortcuts modal, applied without a restart.
|
|
1187
|
+
function setUserKeys(keys) {
|
|
1188
|
+
userKeys = keys ?? {};
|
|
1189
|
+
keymap = null;
|
|
1190
|
+
const message = { type: 'hub:keys', ...keyFields() };
|
|
1191
|
+
for (const appId of liveApps.keys()) postTo(appId, message);
|
|
1192
|
+
for (const win of nestedApps.keys()) postNested(win, message);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1137
1195
|
const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
|
|
1138
1196
|
|
|
1139
1197
|
function onHello(appId, data) {
|
|
@@ -1181,7 +1239,8 @@ function fillUrl(template, params) {
|
|
|
1181
1239
|
function onInvoke({ id, action, params = {}, open }, send) {
|
|
1182
1240
|
if (typeof id !== 'string') return;
|
|
1183
1241
|
const reply = (result) => send({ type: 'hub:result', id, ...result });
|
|
1184
|
-
|
|
1242
|
+
const owned = Object.hasOwn(HUB_OWNED, action) ? HUB_OWNED[action] : null;
|
|
1243
|
+
if (owned?.available()) return owned.run(params, reply);
|
|
1185
1244
|
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
1186
1245
|
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
1187
1246
|
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
@@ -1748,14 +1807,13 @@ function setPaletteHint(message) {
|
|
|
1748
1807
|
drawPaletteHint();
|
|
1749
1808
|
}
|
|
1750
1809
|
|
|
1751
|
-
// The plugin in the active config dir: its version, or a ⚠ that
|
|
1810
|
+
// The plugin in the active config dir: its version, or a ⚠ that opens the update window.
|
|
1752
1811
|
function pluginNode(p) {
|
|
1753
1812
|
const problem = {
|
|
1754
1813
|
missing: { label: 'plugin not installed', cause: `${p.id} is not installed in this config dir.` },
|
|
1755
1814
|
disabled: {
|
|
1756
1815
|
label: `plugin ${p.installed} is off`,
|
|
1757
1816
|
cause: `${p.id} ${p.installed} is disabled. Enable it with /plugin in Claude Code.`,
|
|
1758
|
-
noCopy: true,
|
|
1759
1817
|
},
|
|
1760
1818
|
mismatch: {
|
|
1761
1819
|
label: `plugin ${p.installed}`,
|
|
@@ -1767,11 +1825,11 @@ function pluginNode(p) {
|
|
|
1767
1825
|
badge.type = 'button';
|
|
1768
1826
|
badge.title = problem.cause;
|
|
1769
1827
|
badge.setAttribute('aria-label', problem.cause);
|
|
1770
|
-
if (
|
|
1771
|
-
badge.title +=
|
|
1828
|
+
if (p.state === 'disabled') return badge;
|
|
1829
|
+
badge.title += '\nClick to update the plugins.';
|
|
1772
1830
|
badge.addEventListener('click', (e) => {
|
|
1773
1831
|
e.stopPropagation();
|
|
1774
|
-
|
|
1832
|
+
openUpdate();
|
|
1775
1833
|
});
|
|
1776
1834
|
return badge;
|
|
1777
1835
|
}
|
|
@@ -1952,7 +2010,7 @@ const PALETTE_MODES = {
|
|
|
1952
2010
|
};
|
|
1953
2011
|
|
|
1954
2012
|
// The hub settings modal (Ctrl+,). Each change is saved at once; data is the last /api/settings answer.
|
|
1955
|
-
const settingsModal = { open: false, data: null, error: null, busy: false };
|
|
2013
|
+
const settingsModal = { open: false, data: null, error: null, busy: false, section: 'general' };
|
|
1956
2014
|
|
|
1957
2015
|
const SWITCH_CHOICES = [
|
|
1958
2016
|
['palette', 'Open the list', 'Opens the config-dir list. Pick a dir, or type a path to add one.'],
|
|
@@ -2091,6 +2149,25 @@ function renderSettings() {
|
|
|
2091
2149
|
manage.addEventListener('click', () => openPalette('configDir'));
|
|
2092
2150
|
dirs.append(settingRow('Config dirs', manage));
|
|
2093
2151
|
|
|
2152
|
+
const hubSec = el('section', 'set-sec');
|
|
2153
|
+
hubSec.append(el('h4', '', 'Hub'));
|
|
2154
|
+
const setup = el('button', '', 'Open…');
|
|
2155
|
+
setup.type = 'button';
|
|
2156
|
+
setup.dataset.focus = 'setup';
|
|
2157
|
+
setup.addEventListener('click', () => openSetup());
|
|
2158
|
+
hubSec.append(
|
|
2159
|
+
settingRow(
|
|
2160
|
+
'Setup checklist',
|
|
2161
|
+
setup,
|
|
2162
|
+
'Install the app, start with the system, add config dirs and install plugins.',
|
|
2163
|
+
),
|
|
2164
|
+
);
|
|
2165
|
+
const updates = el('button', '', 'Check…');
|
|
2166
|
+
updates.type = 'button';
|
|
2167
|
+
updates.dataset.focus = 'updates';
|
|
2168
|
+
updates.addEventListener('click', () => openUpdate());
|
|
2169
|
+
hubSec.append(settingRow('Updates', updates, 'The hub version, and the plugins in every config dir.'));
|
|
2170
|
+
|
|
2094
2171
|
const appsSec = el('section', 'set-sec');
|
|
2095
2172
|
appsSec.append(el('h4', '', 'Apps'));
|
|
2096
2173
|
const table = el('table', 'set-apps');
|
|
@@ -2131,24 +2208,66 @@ function renderSettings() {
|
|
|
2131
2208
|
'“Launcher only” keeps the app running but gives it no tab or number key; open it from the app launcher. “On demand” starts it the first time you open it.',
|
|
2132
2209
|
),
|
|
2133
2210
|
);
|
|
2134
|
-
|
|
2211
|
+
const keysSec = el('section', 'set-sec');
|
|
2212
|
+
keysSec.append(el('h4', '', 'Keyboard shortcuts'));
|
|
2213
|
+
const customize = el('button', '', 'Customize…');
|
|
2214
|
+
customize.type = 'button';
|
|
2215
|
+
customize.dataset.focus = 'shortcuts';
|
|
2216
|
+
customize.addEventListener('click', openShortcuts);
|
|
2217
|
+
const custom = Object.keys(s.keys ?? {}).length;
|
|
2218
|
+
keysSec.append(
|
|
2219
|
+
settingRow('Hub keys', customize, custom ? `${custom} changed from the defaults.` : 'All keys are the defaults.'),
|
|
2220
|
+
);
|
|
2221
|
+
const pages = [
|
|
2222
|
+
['general', 'General', [dirs, hubSec]],
|
|
2223
|
+
['apps', 'Apps', [appsSec]],
|
|
2224
|
+
['keys', 'Keyboard', [keysSec]],
|
|
2225
|
+
...s.apps.filter((a) => a.settings?.length).map((a) => [`app:${a.id}`, a.name, [appSettingsSection(a)]]),
|
|
2226
|
+
];
|
|
2227
|
+
const current = pages.find(([id]) => id === settingsModal.section) ?? pages[0];
|
|
2228
|
+
settingsModal.section = current[0];
|
|
2229
|
+
const nav = el('nav', 'set-nav');
|
|
2230
|
+
nav.setAttribute('role', 'tablist');
|
|
2231
|
+
nav.setAttribute('aria-orientation', 'vertical');
|
|
2232
|
+
for (const page of pages) {
|
|
2233
|
+
const tab = el('button', '', page[1]);
|
|
2234
|
+
tab.type = 'button';
|
|
2235
|
+
tab.setAttribute('role', 'tab');
|
|
2236
|
+
tab.setAttribute('aria-selected', String(page === current));
|
|
2237
|
+
tab.tabIndex = page === current ? 0 : -1;
|
|
2238
|
+
tab.dataset.focus = 'page';
|
|
2239
|
+
tab.addEventListener('click', () => {
|
|
2240
|
+
settingsModal.section = page[0];
|
|
2241
|
+
renderSettings();
|
|
2242
|
+
});
|
|
2243
|
+
nav.append(tab);
|
|
2244
|
+
}
|
|
2245
|
+
const pane = el('div', 'set-pane');
|
|
2246
|
+
pane.setAttribute('role', 'tabpanel');
|
|
2247
|
+
pane.append(...current[2]);
|
|
2248
|
+
body.replaceChildren(nav, pane);
|
|
2135
2249
|
|
|
2136
2250
|
let status = el('span', '', 'Saved as you change it');
|
|
2137
2251
|
if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
|
|
2138
2252
|
else if (s.restartNeeded) status = el('span', 'set-restart', 'Restart the hub to apply app changes');
|
|
2139
2253
|
const keys = el('span', 'set-keys');
|
|
2140
|
-
keys.append(kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
|
|
2254
|
+
keys.append(kbd('↑'), kbd('↓'), ' page · ', kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
|
|
2141
2255
|
foot.replaceChildren(status, keys);
|
|
2142
2256
|
|
|
2143
2257
|
// A save redraws the modal, so put focus back on the control that had it.
|
|
2144
2258
|
if (focusKey) {
|
|
2145
|
-
const target =
|
|
2259
|
+
const target =
|
|
2260
|
+
focusKey === 'page'
|
|
2261
|
+
? nav.querySelector('[aria-selected="true"]')
|
|
2262
|
+
: body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
|
|
2146
2263
|
// A radio group gets its checked button; a button that became disabled hands focus to its neighbour.
|
|
2147
2264
|
const focusable = target?.matches('button:not(:disabled)')
|
|
2148
2265
|
? target
|
|
2149
2266
|
: (target?.querySelector('[aria-checked="true"]:not(:disabled)') ??
|
|
2150
2267
|
target?.parentElement.querySelector('button:not(:disabled)'));
|
|
2151
2268
|
(focusable ?? document.querySelector('#settings .settings-box')).focus();
|
|
2269
|
+
} else if (document.activeElement?.matches('#settings .settings-box')) {
|
|
2270
|
+
nav.querySelector('[aria-selected="true"]').focus();
|
|
2152
2271
|
}
|
|
2153
2272
|
}
|
|
2154
2273
|
|
|
@@ -2172,23 +2291,557 @@ function appSettingsSection(a) {
|
|
|
2172
2291
|
return sec;
|
|
2173
2292
|
}
|
|
2174
2293
|
|
|
2294
|
+
const ACTION_NAMES = {
|
|
2295
|
+
'hub.projectPicker': 'Project picker',
|
|
2296
|
+
'hub.configDirPicker': 'Config-dir picker',
|
|
2297
|
+
'hub.appLauncher': 'App launcher',
|
|
2298
|
+
'hub.settings': 'Settings',
|
|
2299
|
+
'hub.prevApp': 'Previous app',
|
|
2300
|
+
'hub.nextApp': 'Next app',
|
|
2301
|
+
'hub.appByNumber': 'App by number',
|
|
2302
|
+
'hub.voice': 'Hold to talk',
|
|
2303
|
+
};
|
|
2304
|
+
|
|
2305
|
+
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Process']);
|
|
2306
|
+
|
|
2307
|
+
const shortcuts = { open: false, recording: null, error: null, busy: false, layout: null };
|
|
2308
|
+
|
|
2309
|
+
const expandCombo = (c) => (c.includes('{n}') ? Array.from({ length: 9 }, (_, i) => c.replace('{n}', i + 1)) : [c]);
|
|
2310
|
+
|
|
2311
|
+
async function openShortcuts() {
|
|
2312
|
+
Object.assign(shortcuts, { open: true, recording: null, error: null });
|
|
2313
|
+
showOverlay('shortcuts', closeShortcuts);
|
|
2314
|
+
renderShortcuts();
|
|
2315
|
+
document.querySelector('#shortcuts .settings-box').focus();
|
|
2316
|
+
try {
|
|
2317
|
+
const [data, layout] = await Promise.all([
|
|
2318
|
+
settingsModal.data ?? sendJson('GET', '/api/settings'),
|
|
2319
|
+
navigator.keyboard?.getLayoutMap?.(),
|
|
2320
|
+
]);
|
|
2321
|
+
settingsModal.data = data;
|
|
2322
|
+
shortcuts.layout = layout ?? null;
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
shortcuts.error ??= err.message;
|
|
2325
|
+
}
|
|
2326
|
+
if (shortcuts.open) renderShortcuts();
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function closeShortcuts() {
|
|
2330
|
+
Object.assign(shortcuts, { open: false, recording: null });
|
|
2331
|
+
hideOverlay('shortcuts');
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Capture phase, so a recorded press never reaches the hub's own bindings or the overlay's Escape.
|
|
2335
|
+
function onShortcutKey(e) {
|
|
2336
|
+
if (!shortcuts.open || shortcuts.busy) return;
|
|
2337
|
+
if (e.key === 'Escape') {
|
|
2338
|
+
e.preventDefault();
|
|
2339
|
+
e.stopPropagation();
|
|
2340
|
+
if (shortcuts.recording) {
|
|
2341
|
+
shortcuts.recording = null;
|
|
2342
|
+
renderShortcuts();
|
|
2343
|
+
} else {
|
|
2344
|
+
closeShortcuts();
|
|
2345
|
+
settingsModal.section = 'keys';
|
|
2346
|
+
openSettings();
|
|
2347
|
+
}
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
if (!shortcuts.recording) return;
|
|
2351
|
+
e.preventDefault();
|
|
2352
|
+
e.stopPropagation();
|
|
2353
|
+
if (e.repeat || MODIFIER_KEYS.has(e.key) || e.isComposing) return;
|
|
2354
|
+
recordKey(shortcuts.recording, comboOf(e));
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function recordKey(id, combo) {
|
|
2358
|
+
const parts = combo.split('+');
|
|
2359
|
+
const fail = (msg) => {
|
|
2360
|
+
shortcuts.error = msg;
|
|
2361
|
+
renderShortcuts();
|
|
2362
|
+
};
|
|
2363
|
+
if (parts.length < 2 || parts.at(-1) === '') return fail('Hold Ctrl, Alt, Shift or ⌘ with the key.');
|
|
2364
|
+
let value = combo;
|
|
2365
|
+
if (id === 'hub.appByNumber') {
|
|
2366
|
+
if (!/\+[1-9]$/.test(combo)) return fail('Press the modifiers with a digit, for example Alt+1.');
|
|
2367
|
+
value = combo.replace(/[1-9]$/, '{n}');
|
|
2368
|
+
}
|
|
2369
|
+
const taken = expandCombo(value);
|
|
2370
|
+
const keeper = settingsModal.data?.keeps?.find((k) => k.combos.some((c) => taken.includes(c)));
|
|
2371
|
+
if (keeper) return fail(`${comboLabel(combo)} is used by ${keeper.name}. Pick another key.`);
|
|
2372
|
+
saveKey(id, value);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// value: a combo, null for no key, undefined for the default. Another action that has the combo
|
|
2376
|
+
// loses it, written as null, so the change shows in its row.
|
|
2377
|
+
async function saveKey(id, value) {
|
|
2378
|
+
const next = { ...userKeys };
|
|
2379
|
+
const action = hubActions().find((a) => a.id === id);
|
|
2380
|
+
if (value === undefined || value === action.combo) delete next[id];
|
|
2381
|
+
else next[id] = value;
|
|
2382
|
+
const mine = new Set(expandCombo(value ?? action.combo));
|
|
2383
|
+
for (const other of hubActions()) {
|
|
2384
|
+
const combo = comboFor(other);
|
|
2385
|
+
if (other.id !== id && combo && expandCombo(combo).some((c) => mine.has(c))) next[other.id] = null;
|
|
2386
|
+
}
|
|
2387
|
+
shortcuts.busy = true;
|
|
2388
|
+
try {
|
|
2389
|
+
settingsModal.data = await sendJson('POST', '/api/settings', { keys: next });
|
|
2390
|
+
setUserKeys(settingsModal.data.keys);
|
|
2391
|
+
shortcuts.error = null;
|
|
2392
|
+
} catch (err) {
|
|
2393
|
+
shortcuts.error = err.message;
|
|
2394
|
+
} finally {
|
|
2395
|
+
shortcuts.busy = false;
|
|
2396
|
+
shortcuts.recording = null;
|
|
2397
|
+
}
|
|
2398
|
+
if (shortcuts.open) renderShortcuts();
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// The combo as the hub names it, plus the character the user's layout prints on that key when it is
|
|
2402
|
+
// not Latin: the hub matches such a key by its US position.
|
|
2403
|
+
function comboNode(combo) {
|
|
2404
|
+
const node = el('span', 'key-combo');
|
|
2405
|
+
node.append(kbd(comboLabel(combo)));
|
|
2406
|
+
const code = codeOf(combo.split('+').at(-1));
|
|
2407
|
+
const local = code && shortcuts.layout?.get(code);
|
|
2408
|
+
if (local && /[^\x20-\x7e]/.test(local)) node.append(el('span', 'key-local', local));
|
|
2409
|
+
return node;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function keyButton(text, focus, disabled, onClick) {
|
|
2413
|
+
const b = el('button', '', text);
|
|
2414
|
+
b.type = 'button';
|
|
2415
|
+
b.dataset.focus = focus;
|
|
2416
|
+
b.disabled = disabled || shortcuts.busy;
|
|
2417
|
+
b.addEventListener('click', onClick);
|
|
2418
|
+
return b;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
function renderShortcuts() {
|
|
2422
|
+
const body = document.getElementById('shortcuts-body');
|
|
2423
|
+
const foot = document.getElementById('shortcuts-foot');
|
|
2424
|
+
const s = settingsModal.data;
|
|
2425
|
+
const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
|
|
2426
|
+
|
|
2427
|
+
const table = el('table', 'set-apps keys-table');
|
|
2428
|
+
const head = el('tr', '');
|
|
2429
|
+
head.append(el('th', '', 'Action'), el('th', '', 'Key'), el('th', '', ''));
|
|
2430
|
+
table.append(head);
|
|
2431
|
+
for (const a of hubActions()) {
|
|
2432
|
+
const combo = comboFor(a);
|
|
2433
|
+
const custom = Object.hasOwn(userKeys, a.id);
|
|
2434
|
+
const tr = el('tr', shortcuts.recording === a.id ? 'recording' : '');
|
|
2435
|
+
const name = el('td', '', ACTION_NAMES[a.id] ?? a.id);
|
|
2436
|
+
if (a.off) name.append(el('span', 'key-tag', 'voice off'));
|
|
2437
|
+
if (custom) name.append(el('span', 'key-tag', 'changed'));
|
|
2438
|
+
const key = el('td', '');
|
|
2439
|
+
if (shortcuts.recording === a.id) key.append(el('span', 'key-prompt', 'Press keys…'));
|
|
2440
|
+
else if (combo) key.append(comboNode(combo));
|
|
2441
|
+
else key.append(el('span', 'set-help', 'No key'));
|
|
2442
|
+
const btns = el('td', 'key-btns');
|
|
2443
|
+
btns.append(
|
|
2444
|
+
keyButton('Change', `rec:${a.id}`, false, () => {
|
|
2445
|
+
shortcuts.recording = a.id;
|
|
2446
|
+
shortcuts.error = null;
|
|
2447
|
+
renderShortcuts();
|
|
2448
|
+
}),
|
|
2449
|
+
keyButton('Reset', `reset:${a.id}`, !custom, () => saveKey(a.id, undefined)),
|
|
2450
|
+
keyButton('Off', `off:${a.id}`, !combo, () => saveKey(a.id, null)),
|
|
2451
|
+
);
|
|
2452
|
+
tr.append(name, key, btns);
|
|
2453
|
+
table.append(tr);
|
|
2454
|
+
}
|
|
2455
|
+
const hubSec = el('section', 'set-sec');
|
|
2456
|
+
hubSec.append(el('h4', '', 'Hub'), table);
|
|
2457
|
+
for (const line of s?.keyWarnings ?? []) hubSec.append(el('span', 'set-help set-error', line));
|
|
2458
|
+
hubSec.append(
|
|
2459
|
+
el(
|
|
2460
|
+
'span',
|
|
2461
|
+
'set-help',
|
|
2462
|
+
'Keys match the character on the key. On a layout with no Latin letters, they match the key in the same place on a US keyboard.',
|
|
2463
|
+
),
|
|
2464
|
+
);
|
|
2465
|
+
|
|
2466
|
+
const sections = [hubSec];
|
|
2467
|
+
for (const app of s?.keeps ?? []) {
|
|
2468
|
+
const sec = el('section', 'set-sec');
|
|
2469
|
+
sec.append(el('h4', '', `${app.name} keeps`));
|
|
2470
|
+
const list = el('div', 'key-keeps');
|
|
2471
|
+
for (const c of app.combos) list.append(comboNode(c));
|
|
2472
|
+
sec.append(list);
|
|
2473
|
+
sections.push(sec);
|
|
2474
|
+
}
|
|
2475
|
+
body.replaceChildren(...sections);
|
|
2476
|
+
|
|
2477
|
+
const status = shortcuts.error
|
|
2478
|
+
? el('span', 'set-error', shortcuts.error)
|
|
2479
|
+
: el('span', '', shortcuts.recording ? 'Press the new key, Esc cancels' : 'Saved as you change it');
|
|
2480
|
+
const hints = el('span', 'set-keys');
|
|
2481
|
+
hints.append(kbd('Tab'), ' move · ', kbd('Esc'), ' back');
|
|
2482
|
+
foot.replaceChildren(status, hints);
|
|
2483
|
+
|
|
2484
|
+
if (focusKey) {
|
|
2485
|
+
const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
|
|
2486
|
+
(target ?? document.querySelector('#shortcuts .settings-box')).focus();
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2175
2490
|
function kbd(text) {
|
|
2176
2491
|
return el('kbd', '', text);
|
|
2177
2492
|
}
|
|
2178
2493
|
|
|
2494
|
+
// Chrome and Edge fire this once per load, only while the page can be installed and is not yet.
|
|
2495
|
+
let installPrompt = null;
|
|
2496
|
+
window.addEventListener('beforeinstallprompt', (e) => {
|
|
2497
|
+
e.preventDefault();
|
|
2498
|
+
installPrompt = e;
|
|
2499
|
+
if (setupModal.open) renderSetup();
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
const setupModal = {
|
|
2503
|
+
open: false,
|
|
2504
|
+
mode: 'setup',
|
|
2505
|
+
data: null,
|
|
2506
|
+
update: null,
|
|
2507
|
+
updatedDirs: new Set(),
|
|
2508
|
+
error: null,
|
|
2509
|
+
busy: null,
|
|
2510
|
+
output: null,
|
|
2511
|
+
};
|
|
2512
|
+
const isInstalledApp = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
|
2513
|
+
const IS_SAFARI = /^((?!chrome|android|edg).)*safari/i.test(navigator.userAgent);
|
|
2514
|
+
|
|
2515
|
+
// Every step is read from the machine each time, so a step done by hand shows as done. Only
|
|
2516
|
+
// finishing or skipping is saved.
|
|
2517
|
+
function setupSteps(s) {
|
|
2518
|
+
if (setupModal.mode === 'update')
|
|
2519
|
+
return [
|
|
2520
|
+
{ id: 'hub', done: !setupModal.update?.update },
|
|
2521
|
+
{ id: 'plugins', done: s.pluginsDone },
|
|
2522
|
+
];
|
|
2523
|
+
const steps = [{ id: 'app', done: isInstalledApp() }];
|
|
2524
|
+
if (s.tray.supported) steps.push({ id: 'tray', done: s.tray.autostart });
|
|
2525
|
+
steps.push({ id: 'dirs', done: s.candidates.length === 0 }, { id: 'plugins', done: s.pluginsDone });
|
|
2526
|
+
return steps;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function maybeShowSetup() {
|
|
2530
|
+
let data;
|
|
2531
|
+
try {
|
|
2532
|
+
data = await sendJson('GET', '/api/setup');
|
|
2533
|
+
} catch {
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
if (setupSteps(data).every((st) => st.done)) {
|
|
2537
|
+
sendJson('POST', '/api/setup', { onboarding: 'done' }).catch(() => {});
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
if (openOverlay) return;
|
|
2541
|
+
openSetup(data);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
async function openSetup(data = null, mode = 'setup', update = null) {
|
|
2545
|
+
setupModal.open = true;
|
|
2546
|
+
setupModal.mode = mode;
|
|
2547
|
+
setupModal.error = null;
|
|
2548
|
+
setupModal.output = null;
|
|
2549
|
+
setupModal.updatedDirs.clear();
|
|
2550
|
+
setupModal.data = data;
|
|
2551
|
+
setupModal.update = update;
|
|
2552
|
+
showOverlay('setup', closeSetup);
|
|
2553
|
+
renderSetup();
|
|
2554
|
+
document.querySelector('#setup .setup-box').focus();
|
|
2555
|
+
const needUpdate = mode === 'update' && !update;
|
|
2556
|
+
if (data && !needUpdate) return;
|
|
2557
|
+
try {
|
|
2558
|
+
const [setup, info] = await Promise.all([
|
|
2559
|
+
data ?? sendJson('GET', '/api/setup'),
|
|
2560
|
+
needUpdate ? sendJson('GET', '/api/update') : update,
|
|
2561
|
+
]);
|
|
2562
|
+
setupModal.update = info;
|
|
2563
|
+
setupModal.data = setup;
|
|
2564
|
+
} catch (err) {
|
|
2565
|
+
setupModal.error = err.message;
|
|
2566
|
+
}
|
|
2567
|
+
if (setupModal.open) renderSetup();
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function openUpdate() {
|
|
2571
|
+
return openSetup(null, 'update');
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function closeSetup() {
|
|
2575
|
+
setupModal.open = false;
|
|
2576
|
+
hideOverlay('setup');
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
async function endSetup(value) {
|
|
2580
|
+
try {
|
|
2581
|
+
await sendJson('POST', '/api/setup', { onboarding: value });
|
|
2582
|
+
} catch {}
|
|
2583
|
+
closeSetup();
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
async function setupAction(key, fn) {
|
|
2587
|
+
if (setupModal.busy) return;
|
|
2588
|
+
setupModal.busy = key;
|
|
2589
|
+
setupModal.error = null;
|
|
2590
|
+
setupModal.output = null;
|
|
2591
|
+
renderSetup();
|
|
2592
|
+
try {
|
|
2593
|
+
const r = await fn();
|
|
2594
|
+
if (r?.setup) setupModal.data = r.setup;
|
|
2595
|
+
noteUpdatedDirs(r?.results);
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
setupModal.error = err.message;
|
|
2598
|
+
setupModal.output = err.data?.output ? { step: key.split(':')[0], text: err.data.output } : null;
|
|
2599
|
+
if (err.data?.setup) setupModal.data = err.data.setup;
|
|
2600
|
+
noteUpdatedDirs(err.data?.results);
|
|
2601
|
+
} finally {
|
|
2602
|
+
setupModal.busy = null;
|
|
2603
|
+
}
|
|
2604
|
+
if (setupModal.open) renderSetup();
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
function noteUpdatedDirs(results) {
|
|
2608
|
+
for (const r of results ?? []) if (r.ok) setupModal.updatedDirs.add(r.dir);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function setupButton(text, key, fn, primary = false) {
|
|
2612
|
+
const b = el('button', primary ? 'primary' : '', setupModal.busy === key ? 'Working…' : text);
|
|
2613
|
+
b.type = 'button';
|
|
2614
|
+
b.disabled = !!setupModal.busy;
|
|
2615
|
+
b.dataset.focus = key;
|
|
2616
|
+
b.addEventListener('click', () => setupAction(key, fn));
|
|
2617
|
+
return b;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function installStep(step) {
|
|
2621
|
+
const action =
|
|
2622
|
+
installPrompt && !step.done
|
|
2623
|
+
? setupButton(
|
|
2624
|
+
'Install',
|
|
2625
|
+
'app',
|
|
2626
|
+
async () => {
|
|
2627
|
+
const prompt = installPrompt;
|
|
2628
|
+
installPrompt = null;
|
|
2629
|
+
await prompt.prompt();
|
|
2630
|
+
await prompt.userChoice;
|
|
2631
|
+
},
|
|
2632
|
+
true,
|
|
2633
|
+
)
|
|
2634
|
+
: null;
|
|
2635
|
+
let desc = 'The hub runs in its own window, with no browser tabs or address bar.';
|
|
2636
|
+
if (step.done) desc = 'The hub runs as an installed app.';
|
|
2637
|
+
else if (!installPrompt)
|
|
2638
|
+
desc += IS_SAFARI
|
|
2639
|
+
? ' In Safari, choose File → Add to Dock.'
|
|
2640
|
+
: ' Use the install icon in the address bar, or the browser menu → Install. Already installed? Open it from the Start menu or Applications.';
|
|
2641
|
+
return { title: 'Install as an app', desc, action };
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function trayStep(step, s) {
|
|
2645
|
+
const where = IS_MAC ? 'menu bar' : 'notification area';
|
|
2646
|
+
const desc = step.done
|
|
2647
|
+
? `Starts when you sign in. Tray icon: ${s.tray.running ? 'running' : 'not running'}.`
|
|
2648
|
+
: `Adds an icon to the ${where} and starts the hub when you sign in.`;
|
|
2649
|
+
const action = step.done ? null : setupButton('Turn on', 'tray', () => sendJson('POST', '/api/setup/tray'), true);
|
|
2650
|
+
return { title: 'Autostart', tag: IS_MAC ? 'experimental' : null, desc, action };
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
function dirsStep(step, s) {
|
|
2654
|
+
const list = el('div', 'setup-list');
|
|
2655
|
+
for (const d of s.dirs) {
|
|
2656
|
+
const item = el('div', 'setup-item');
|
|
2657
|
+
item.append(el('code', '', d.path), el('span', 'setup-ok', d.active ? 'active' : 'added'));
|
|
2658
|
+
list.append(item);
|
|
2659
|
+
}
|
|
2660
|
+
for (const path of s.candidates) {
|
|
2661
|
+
const item = el('div', 'setup-item');
|
|
2662
|
+
const add = setupButton('Add', `dir:${path}`, async () => {
|
|
2663
|
+
await sendJson('POST', '/api/config-dirs', { path });
|
|
2664
|
+
return { setup: await sendJson('GET', '/api/setup') };
|
|
2665
|
+
});
|
|
2666
|
+
item.append(el('code', '', path), add);
|
|
2667
|
+
list.append(item);
|
|
2668
|
+
}
|
|
2669
|
+
const desc = step.done
|
|
2670
|
+
? 'The hub lists every Claude config dir it found. Switch dirs with the config-dir key.'
|
|
2671
|
+
: 'Found more Claude config dirs. Each dir you add gets its own Kanban, Cost and Memory.';
|
|
2672
|
+
return { title: 'Choose Claude config dirs', desc, extra: list };
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const HUB_UPDATE_COMMAND = 'npm i -g claude-code-hub@latest';
|
|
2676
|
+
|
|
2677
|
+
function hubStep(step) {
|
|
2678
|
+
const u = setupModal.update;
|
|
2679
|
+
if (step.done)
|
|
2680
|
+
return { title: 'Update the hub', desc: `Version ${u?.version ?? '?'}. No newer version found on npm.` };
|
|
2681
|
+
const item = el('div', 'setup-item');
|
|
2682
|
+
const copy = el('button', 'primary', 'Copy');
|
|
2683
|
+
copy.type = 'button';
|
|
2684
|
+
copy.dataset.focus = 'copy';
|
|
2685
|
+
copy.addEventListener('click', async () => {
|
|
2686
|
+
try {
|
|
2687
|
+
await navigator.clipboard.writeText(HUB_UPDATE_COMMAND);
|
|
2688
|
+
copy.textContent = 'Copied';
|
|
2689
|
+
} catch {
|
|
2690
|
+
copy.textContent = 'Copy failed';
|
|
2691
|
+
}
|
|
2692
|
+
});
|
|
2693
|
+
const notes = el('a', '', 'Release notes');
|
|
2694
|
+
notes.href = u.update.url;
|
|
2695
|
+
notes.target = '_blank';
|
|
2696
|
+
notes.rel = 'noopener';
|
|
2697
|
+
item.append(el('code', '', HUB_UPDATE_COMMAND), copy, notes);
|
|
2698
|
+
const list = el('div', 'setup-list');
|
|
2699
|
+
list.append(item);
|
|
2700
|
+
return {
|
|
2701
|
+
title: 'Update the hub',
|
|
2702
|
+
tag: `${u.version} → ${u.update.latest}`,
|
|
2703
|
+
desc: 'Stop the hub, run this command, then start the hub again. It also updates Kanban, Cost, Marketplace and Memory.',
|
|
2704
|
+
extra: list,
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function pluginNote(p) {
|
|
2709
|
+
if (p.state === 'mismatch') return `${p.name}: ${p.installed} → ${p.bundled}`;
|
|
2710
|
+
return `${p.name}: ${p.state === 'missing' ? 'not installed' : 'turned off'}`;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function pluginsStep(step, s) {
|
|
2714
|
+
const update = setupModal.mode === 'update';
|
|
2715
|
+
const list = el('div', 'setup-list');
|
|
2716
|
+
for (const d of s.dirs) {
|
|
2717
|
+
const item = el('div', 'setup-item');
|
|
2718
|
+
const right = d.pending
|
|
2719
|
+
? setupButton(update ? 'Update' : 'Install', `plugins:${d.path}`, () =>
|
|
2720
|
+
sendJson('POST', '/api/setup/plugins', { dir: d.path }),
|
|
2721
|
+
)
|
|
2722
|
+
: el('span', 'setup-ok good', update ? '✓ up to date' : '✓ installed');
|
|
2723
|
+
item.append(el('code', '', d.path), right);
|
|
2724
|
+
const notes = d.plugins.filter((p) => p.state !== 'ok').map(pluginNote);
|
|
2725
|
+
if (notes.length) item.append(el('span', 'set-help', notes.join(' · ')));
|
|
2726
|
+
list.append(item);
|
|
2727
|
+
}
|
|
2728
|
+
if (setupModal.updatedDirs.size)
|
|
2729
|
+
list.append(
|
|
2730
|
+
el(
|
|
2731
|
+
'div',
|
|
2732
|
+
'setup-notice',
|
|
2733
|
+
`Restart open Claude sessions in these dirs to load the new plugins: ${[...setupModal.updatedDirs].join(', ')}`,
|
|
2734
|
+
),
|
|
2735
|
+
);
|
|
2736
|
+
const action =
|
|
2737
|
+
s.dirs.filter((d) => d.pending).length > 1
|
|
2738
|
+
? setupButton(
|
|
2739
|
+
update ? 'Update all dirs' : 'Install in all dirs',
|
|
2740
|
+
'plugins:all',
|
|
2741
|
+
() => sendJson('POST', '/api/setup/plugins', { all: true }),
|
|
2742
|
+
true,
|
|
2743
|
+
)
|
|
2744
|
+
: null;
|
|
2745
|
+
return {
|
|
2746
|
+
title: update ? 'Update plugins' : 'Install plugins',
|
|
2747
|
+
desc: update
|
|
2748
|
+
? 'The hub plugin and the app plugins in every config dir. A dir that fails does not stop the others.'
|
|
2749
|
+
: 'Kanban shows live agent activity only in a dir with its plugin. Open sessions keep the old plugin until they restart.',
|
|
2750
|
+
action,
|
|
2751
|
+
extra: list,
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const SETUP_STEP_VIEWS = { app: installStep, tray: trayStep, dirs: dirsStep, plugins: pluginsStep, hub: hubStep };
|
|
2756
|
+
|
|
2757
|
+
const SETUP_MODES = {
|
|
2758
|
+
setup: {
|
|
2759
|
+
title: 'Welcome to Claude Code Hub',
|
|
2760
|
+
sub: 'A few quick steps make the hub feel like a desktop app.',
|
|
2761
|
+
status: 'Skip any step. Open this again from Hub settings.',
|
|
2762
|
+
end: (allDone) => [allDone ? 'Finish' : 'Skip setup', () => endSetup(allDone ? 'done' : 'skipped')],
|
|
2763
|
+
},
|
|
2764
|
+
update: {
|
|
2765
|
+
title: 'Updates',
|
|
2766
|
+
sub: 'Nothing changes until you click. Restart the hub by hand.',
|
|
2767
|
+
status: 'Open this again from Hub settings → Updates.',
|
|
2768
|
+
end: () => ['Close', closeSetup],
|
|
2769
|
+
},
|
|
2770
|
+
};
|
|
2771
|
+
|
|
2772
|
+
function renderSetup() {
|
|
2773
|
+
const body = document.getElementById('setup-body');
|
|
2774
|
+
const foot = document.getElementById('setup-foot');
|
|
2775
|
+
const s = setupModal.data;
|
|
2776
|
+
const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
|
|
2777
|
+
const mode = SETUP_MODES[setupModal.mode];
|
|
2778
|
+
document.getElementById('setup-title').textContent = mode.title;
|
|
2779
|
+
document.getElementById('setup-sub').textContent = mode.sub;
|
|
2780
|
+
if (!s) {
|
|
2781
|
+
body.replaceChildren(el('div', 'set-help', setupModal.error ?? 'Loading…'));
|
|
2782
|
+
foot.replaceChildren();
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const steps = setupSteps(s);
|
|
2786
|
+
const doneCount = steps.filter((st) => st.done).length;
|
|
2787
|
+
const current = steps.find((st) => !st.done);
|
|
2788
|
+
const meter = el('div', 'setup-meter');
|
|
2789
|
+
const lead = el('span', 'setup-lead');
|
|
2790
|
+
lead.append(el('b', '', `${doneCount} of ${steps.length}`), ' done');
|
|
2791
|
+
const bar = el('div', 'setup-progress');
|
|
2792
|
+
const fill = el('i', '');
|
|
2793
|
+
fill.style.width = `${(doneCount / steps.length) * 100}%`;
|
|
2794
|
+
bar.append(fill);
|
|
2795
|
+
meter.append(lead, bar);
|
|
2796
|
+
const list = el('ol', 'setup-steps');
|
|
2797
|
+
steps.forEach((st, i) => {
|
|
2798
|
+
const view = SETUP_STEP_VIEWS[st.id](st, s);
|
|
2799
|
+
const li = el('li', `setup-step${st.done ? ' done' : ''}${st === current ? ' current' : ''}`);
|
|
2800
|
+
const title = el('span', 'setup-title', view.title);
|
|
2801
|
+
if (view.tag) title.append(el('span', 'setup-tag', view.tag));
|
|
2802
|
+
li.append(el('span', 'setup-mark', st.done ? '✓' : String(i + 1)), title, view.action ?? el('span', ''));
|
|
2803
|
+
li.append(el('span', 'setup-desc', view.desc));
|
|
2804
|
+
if (view.extra) li.append(view.extra);
|
|
2805
|
+
if (setupModal.output?.step === st.id) li.append(el('pre', 'setup-out', setupModal.output.text));
|
|
2806
|
+
list.append(li);
|
|
2807
|
+
});
|
|
2808
|
+
body.replaceChildren(meter, list);
|
|
2809
|
+
|
|
2810
|
+
const status = setupModal.error ? el('span', 'set-error', setupModal.error) : el('span', 'set-keys', mode.status);
|
|
2811
|
+
const allDone = doneCount === steps.length;
|
|
2812
|
+
const [endText, onEnd] = mode.end(allDone);
|
|
2813
|
+
const end = el('button', allDone ? 'primary' : '', endText);
|
|
2814
|
+
end.type = 'button';
|
|
2815
|
+
end.dataset.focus = 'end';
|
|
2816
|
+
end.addEventListener('click', () => onEnd());
|
|
2817
|
+
foot.replaceChildren(status, end);
|
|
2818
|
+
|
|
2819
|
+
if (focusKey) {
|
|
2820
|
+
const target = document.querySelector(`#setup [data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
|
|
2821
|
+
(target ?? document.querySelector('#setup .setup-box')).focus();
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
function bindSetup() {
|
|
2826
|
+
const overlay = document.getElementById('setup');
|
|
2827
|
+
overlay.addEventListener('mousedown', (e) => {
|
|
2828
|
+
if (e.target === overlay) closeSetup();
|
|
2829
|
+
});
|
|
2830
|
+
matchMedia('(display-mode: standalone)').addEventListener('change', () => setupModal.open && renderSetup());
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2179
2833
|
function bindSettings() {
|
|
2180
2834
|
const overlay = document.getElementById('settings');
|
|
2181
2835
|
overlay.addEventListener('mousedown', (e) => {
|
|
2182
2836
|
if (e.target === overlay) closeSettings();
|
|
2183
2837
|
});
|
|
2184
2838
|
overlay.addEventListener('keydown', (e) => {
|
|
2185
|
-
|
|
2186
|
-
const group = e.target.closest('[role="radiogroup"]');
|
|
2187
|
-
if (!group) return;
|
|
2839
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -1, ArrowDown: 1 }[e.key];
|
|
2840
|
+
const group = step && e.target.closest('[role="radiogroup"], [role="tablist"]');
|
|
2841
|
+
if (!group || (group.getAttribute('role') === 'tablist') !== (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return;
|
|
2188
2842
|
e.preventDefault();
|
|
2189
|
-
const
|
|
2190
|
-
|
|
2191
|
-
radios[i + (e.key === 'ArrowRight' ? 1 : -1)]?.click();
|
|
2843
|
+
const items = [...group.querySelectorAll('[role="radio"]:not(:disabled), [role="tab"]')];
|
|
2844
|
+
items[items.indexOf(e.target) + step]?.click();
|
|
2192
2845
|
});
|
|
2193
2846
|
}
|
|
2194
2847
|
|